Compare commits
23 Commits
feature/to
...
b53dfe3601
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53dfe3601 | ||
|
|
b8d6fa28df | ||
|
|
975411598d | ||
|
|
09cc22e4a4 | ||
|
|
954d8879b7 | ||
|
|
8a7697447f | ||
|
|
dfaeae7e01 | ||
|
|
58960af817 | ||
|
|
18c2301cdf | ||
|
|
4cddb4bfe9 | ||
|
|
a8df07d085 | ||
|
|
376edbbcfb | ||
|
|
33e1261b38 | ||
|
|
ad4758d0ba | ||
|
|
93257b1bc6 | ||
|
|
04497a608c | ||
|
|
8f1c64ac58 | ||
|
|
34e678ad63 | ||
|
|
46df842732 | ||
|
|
f1ca0d6df1 | ||
|
|
1cb1d90a7e | ||
|
|
019cbe4199 | ||
|
|
206095a7a8 |
@@ -59,7 +59,7 @@ jobs:
|
||||
# 4. 마운트된 /app 디렉토리로 이동하여 호스트의 도커 컴포즈 제어!
|
||||
cd /app
|
||||
docker system prune -f
|
||||
ACTIVE_PROFILE=dev docker compose up -d --build --pull always --remove-orphans gateway redis mci-mock dozzle was-sms was-oth
|
||||
ACTIVE_PROFILE=dev docker compose up -d --build --pull always --remove-orphans gateway tool-report mci-mock dozzle was-sal was-cus was-pro was-sys
|
||||
|
||||
# 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 및 빌드 캐시 자동 소거 청소!
|
||||
docker image prune -a -f
|
||||
|
||||
34
README.md
34
README.md
@@ -1,5 +1,27 @@
|
||||
# AX HUB MCP Tool Platform
|
||||
|
||||
## Current Tool Pod modules
|
||||
|
||||
| Pod role | Gradle module | Container service | Internal endpoint | Local Docker endpoint |
|
||||
|---|---|---|---|---|
|
||||
| Customer/common integration | `dap-was-cus` | `was-cus` | `http://was-cus:8084/mcp` | `http://localhost:8284/mcp` |
|
||||
| Sales/notification integration | `dap-was-sal` | `was-sal` | `http://was-sal:8082/mcp` | `http://localhost:8282/mcp` |
|
||||
| Process integration (new) | `dap-was-pro` | `was-pro` | `http://was-pro:8085/mcp` | `http://localhost:8085/mcp` |
|
||||
| System integration (new) | `dap-was-sys` | `was-sys` | `http://was-sys:8086/mcp` | `http://localhost:8086/mcp` |
|
||||
|
||||
- The former `dap-was-oth` module is now `dap-was-cus`; the former `dap-was-sms` module is now `dap-was-sal`.
|
||||
- This is a deployment Pod rename only. Existing tool category and function names such as `oth_*` and `sms_*` remain valid so already registered MCP clients are not broken.
|
||||
- `dap-was-pro` and `dap-was-sys` are empty, independently deployable Tool Pods ready for new business tools.
|
||||
|
||||
### Run the Tool Pods locally
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :dap-was-cus:bootRun
|
||||
.\gradlew.bat :dap-was-sal:bootRun
|
||||
.\gradlew.bat :dap-was-pro:bootRun
|
||||
.\gradlew.bat :dap-was-sys:bootRun
|
||||
```
|
||||
|
||||
AX HUB에서 AI Agent가 업무 Tool을 검색하고 호출할 수 있도록 Gateway와 독립 Tool Pod를 제공하는 멀티 모듈 Spring Boot 프로젝트입니다.
|
||||
|
||||
## 1. 현재 구성
|
||||
@@ -61,8 +83,10 @@ $env:OPENROUTER_API_KEY = '<발급받은-키>'
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :dap-gateway:bootRun
|
||||
.\gradlew.bat :dap-was-sms:bootRun
|
||||
.\gradlew.bat :dap-was-oth:bootRun
|
||||
.\gradlew.bat :dap-was-sal:bootRun
|
||||
.\gradlew.bat :dap-was-cus:bootRun
|
||||
.\gradlew.bat :dap-was-pro:bootRun
|
||||
.\gradlew.bat :dap-was-sys:bootRun
|
||||
```
|
||||
|
||||
### 4.2 Docker Compose 실행
|
||||
@@ -227,8 +251,10 @@ https://dev-ichmci.shinhanlife.co.kr/ntl_mci/clc_rcv
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :dap-was-lib:test
|
||||
.\gradlew.bat :dap-was-oth:compileJava
|
||||
.\gradlew.bat :dap-was-sms:compileJava
|
||||
.\gradlew.bat :dap-was-cus:compileJava
|
||||
.\gradlew.bat :dap-was-sal:compileJava
|
||||
.\gradlew.bat :dap-was-pro:compileJava
|
||||
.\gradlew.bat :dap-was-sys:compileJava
|
||||
.\gradlew.bat validateMcpToolNames
|
||||
```
|
||||
|
||||
|
||||
@@ -38,8 +38,14 @@ subprojects {
|
||||
// Spring Boot 3.5.11과 호환되는 Spring/Jackson/Tomcat 등의 버전을 관리합니다.
|
||||
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
|
||||
|
||||
// Spring AI, MCP, OpenAI 연동 라이브러리의 호환 버전을 1.1.8로 고정합니다.
|
||||
// Boot 3.5.11과 호환되는 Spring AI 계층은 1.1.8로 유지합니다.
|
||||
mavenBom 'org.springframework.ai:spring-ai-bom:1.1.8'
|
||||
|
||||
// MCP Java SDK만 2.0.0으로 올립니다. Spring AI 2.x 전체 BOM은 Boot 4 기반이므로 사용하지 않습니다.
|
||||
mavenBom 'io.modelcontextprotocol.sdk:mcp-bom:2.0.0'
|
||||
|
||||
// MCP SDK 2.0의 Jackson 2 전송 모듈이 요구하는 호환 버전입니다.
|
||||
mavenBom 'com.fasterxml.jackson:jackson-bom:2.20.1'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,12 @@ dependencies {
|
||||
// 등록된 Excel 양식을 보존하면서 Tool 문서를 생성합니다.
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// Tool Registry 및 분산 캐시 연동에 사용합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// Tool/Registry 관련 DB 조회와 MyBatis Mapper 실행에 사용합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
||||
|
||||
// Gateway가 /mcp Endpoint를 MCP Server로 노출하도록 지원합니다.
|
||||
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
||||
// Gateway MCP Server/Client는 공통 모듈이 제공하는 MCP Java SDK 2.0.0을 사용합니다.
|
||||
|
||||
// ChatClient를 통해 OpenAI/OpenRouter 호환 모델을 호출합니다.
|
||||
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||
|
||||
@@ -37,8 +37,8 @@ public class McpGatewayProperties {
|
||||
private Boolean agentClaimsRequired;
|
||||
private Boolean trustedClaimsRequired;
|
||||
private Boolean writeApprovalRequired;
|
||||
private Boolean redisTraceEnabled;
|
||||
private Long redisTraceTtlSeconds;
|
||||
private Boolean traceEnabled;
|
||||
private Long traceRetentionSize;
|
||||
private Long toolTimeoutMillis;
|
||||
private Integer retryMaxAttempts;
|
||||
private Long retryInitialBackoffMillis;
|
||||
@@ -70,8 +70,8 @@ public class McpGatewayProperties {
|
||||
public void setAgentClaimsRequired(Boolean agentClaimsRequired) { this.agentClaimsRequired = agentClaimsRequired; }
|
||||
public void setTrustedClaimsRequired(Boolean trustedClaimsRequired) { this.trustedClaimsRequired = trustedClaimsRequired; }
|
||||
public void setWriteApprovalRequired(Boolean writeApprovalRequired) { this.writeApprovalRequired = writeApprovalRequired; }
|
||||
public void setRedisTraceEnabled(Boolean redisTraceEnabled) { this.redisTraceEnabled = redisTraceEnabled; }
|
||||
public void setRedisTraceTtlSeconds(Long redisTraceTtlSeconds) { this.redisTraceTtlSeconds = redisTraceTtlSeconds; }
|
||||
public void setTraceEnabled(Boolean traceEnabled) { this.traceEnabled = traceEnabled; }
|
||||
public void setTraceRetentionSize(Long traceRetentionSize) { this.traceRetentionSize = traceRetentionSize; }
|
||||
public void setToolTimeoutMillis(Long toolTimeoutMillis) { this.toolTimeoutMillis = toolTimeoutMillis; }
|
||||
public void setRetryMaxAttempts(Integer retryMaxAttempts) { this.retryMaxAttempts = retryMaxAttempts; }
|
||||
public void setRetryInitialBackoffMillis(Long retryInitialBackoffMillis) { this.retryInitialBackoffMillis = retryInitialBackoffMillis; }
|
||||
@@ -103,8 +103,8 @@ public class McpGatewayProperties {
|
||||
public boolean agentClaimsRequired() { return agentClaimsRequired == null || agentClaimsRequired; }
|
||||
public boolean trustedClaimsRequired() { return trustedClaimsRequired == null || trustedClaimsRequired; }
|
||||
public boolean writeApprovalRequired() { return writeApprovalRequired == null || writeApprovalRequired; }
|
||||
public boolean redisTraceEnabled() { return redisTraceEnabled == null || redisTraceEnabled; }
|
||||
public long redisTraceTtlSeconds() { return redisTraceTtlSeconds == null || redisTraceTtlSeconds < 1 ? 3_600 : redisTraceTtlSeconds; }
|
||||
public boolean traceEnabled() { return traceEnabled == null || traceEnabled; }
|
||||
public long traceRetentionSize() { return traceRetentionSize == null || traceRetentionSize < 1 ? 1000 : traceRetentionSize; }
|
||||
public long toolTimeoutMillis() { return toolTimeoutMillis == null || toolTimeoutMillis < 1 ? 5_000 : toolTimeoutMillis; }
|
||||
public int retryMaxAttempts() { return retryMaxAttempts == null || retryMaxAttempts < 1 ? 3 : retryMaxAttempts; }
|
||||
public long retryInitialBackoffMillis() { return retryInitialBackoffMillis == null || retryInitialBackoffMillis < 1 ? 1_000 : retryInitialBackoffMillis; }
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package io.shinhanlife.dap.mcg.config;
|
||||
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.config
|
||||
* @className RedisConfig
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, ToolMetadata> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, ToolMetadata> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// 1. Key는 무조건 String
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// 2. Value는 Generic 직렬화기 사용 (생성자 인자 없음)
|
||||
Jackson2JsonRedisSerializer<ToolMetadata> serializer = new Jackson2JsonRedisSerializer<>(ToolMetadata.class);
|
||||
template.setValueSerializer(serializer);
|
||||
template.setHashValueSerializer(serializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ package io.shinhanlife.dap.mcg.presentation;
|
||||
*/
|
||||
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
|
||||
import io.shinhanlife.dap.mcg.service.ExecuteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -41,7 +41,7 @@ public class ChatController {
|
||||
|
||||
private final ExecuteService executeService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisRegistryService registryService;
|
||||
private final InMemoryRegistryService registryService;
|
||||
private final ChatClient.Builder chatClientBuilder;
|
||||
private final McpRouterController mcpRouterController;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.lib.adapter.dto.Params;
|
||||
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
|
||||
import io.shinhanlife.dap.mcg.service.ExecuteService;
|
||||
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -49,19 +49,19 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
@Tag(name = "MCP Router API", description = "AI Agent의 요청을 받아 Adapter 시스템으로 라우팅하는 게이트웨이 API")
|
||||
public class McpRouterController {
|
||||
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
private final InMemoryRegistryService registryService;
|
||||
private final ExecuteService executeService;
|
||||
private final SecurityProperties securityProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final GatewayFallbackProperties gatewayFallbackProperties;
|
||||
private final RestClient restClient;
|
||||
|
||||
public McpRouterController(RedisRegistryService redisRegistryService,
|
||||
public McpRouterController(InMemoryRegistryService registryService,
|
||||
ExecuteService executeService,
|
||||
SecurityProperties securityProperties,
|
||||
ObjectMapper objectMapper,
|
||||
GatewayFallbackProperties gatewayFallbackProperties) {
|
||||
this.redisRegistryService = redisRegistryService;
|
||||
this.registryService = registryService;
|
||||
this.executeService = executeService;
|
||||
this.securityProperties = securityProperties;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -115,12 +115,12 @@ public class McpRouterController {
|
||||
private List<ToolMetadata> fetchAllActiveTools() {
|
||||
List<ToolMetadata> activeTools = new ArrayList<>();
|
||||
try {
|
||||
activeTools.addAll(redisRegistryService.getAllTools()
|
||||
activeTools.addAll(registryService.getAllTools()
|
||||
.stream()
|
||||
.filter(ToolMetadata::getVisible)
|
||||
.collect(Collectors.toList()));
|
||||
} catch (org.springframework.data.redis.RedisConnectionFailureException exception) {
|
||||
log.warn("Redis is unavailable. Fetching tools from configured fallback Tool Pods instead.");
|
||||
} catch (Exception exception) {
|
||||
log.warn("Registry is unavailable. Fetching tools from configured fallback Tool Pods instead.");
|
||||
}
|
||||
|
||||
Set<String> knownTools = activeTools.stream()
|
||||
@@ -166,9 +166,38 @@ public class McpRouterController {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
List<Map<String, Object>> mappedTools = activeTools.stream().map(t -> {
|
||||
Map<String, Object> toolMap = new HashMap<>();
|
||||
toolMap.put("name", t.getName());
|
||||
toolMap.put("description", t.getDescription());
|
||||
toolMap.put("inputSchema", t.getParametersSchema());
|
||||
toolMap.put("outputSchema", t.getOutputSchema());
|
||||
toolMap.put("title", t.getDisplayName());
|
||||
|
||||
Map<String, Object> annotations = new HashMap<>();
|
||||
annotations.put("readOnlyHint", t.getReadOnlyHint() != null ? t.getReadOnlyHint() : false);
|
||||
annotations.put("destructiveHint", t.getDestructiveHint() != null ? t.getDestructiveHint() : true);
|
||||
annotations.put("idempotentHint", t.getIdempotentHint() != null ? t.getIdempotentHint() : false);
|
||||
toolMap.put("annotations", annotations);
|
||||
|
||||
Map<String, Object> meta = new HashMap<>();
|
||||
meta.put("version", t.getSemver());
|
||||
meta.put("category_key", t.getCategoryKey());
|
||||
meta.put("tags", t.getTags());
|
||||
meta.put("owner_org", t.getOwnerOrg());
|
||||
meta.put("legacy_interface_id", t.getMciServiceId());
|
||||
meta.put("display_description", t.getDisplayDescription());
|
||||
meta.put("example_queries", t.getExampleQueries());
|
||||
meta.put("example_queries", t.getExampleQueries());
|
||||
meta.put("required_env_keys", t.getRequiredEnvKeys());
|
||||
|
||||
toolMap.put("_meta", meta);
|
||||
return toolMap;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
JsonRpcResponse response = new JsonRpcResponse();
|
||||
response.setId(UUID.randomUUID().toString());
|
||||
response.setResult(Map.of("tools", activeTools));
|
||||
response.setResult(Map.of("tools", mappedTools));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
@@ -240,23 +269,15 @@ public class McpRouterController {
|
||||
|
||||
@PostMapping("/registry/register")
|
||||
public ResponseEntity<String> registerTool(@RequestBody ToolMetadata meta) {
|
||||
redisRegistryService.saveTool(meta);
|
||||
registryService.saveTool(meta);
|
||||
return ResponseEntity.ok("Registered");
|
||||
}
|
||||
|
||||
@PostMapping("/registry/deregister")
|
||||
public ResponseEntity<String> deregisterTool(@RequestBody String uid) {
|
||||
redisRegistryService.removeTool(uid);
|
||||
registryService.removeTool(uid);
|
||||
return ResponseEntity.ok("Deregistered");
|
||||
}
|
||||
|
||||
@PostMapping("/registry/heartbeat")
|
||||
public ResponseEntity<String> heartbeat(@RequestBody String uid) {
|
||||
boolean success = redisRegistryService.refreshHeartbeat(uid);
|
||||
if (success) {
|
||||
return ResponseEntity.ok("Heartbeat updated");
|
||||
} else {
|
||||
return ResponseEntity.status(404).body("Tool not found");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,7 +39,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class ScaffoldingController {
|
||||
|
||||
private static final Set<String> SUPPORTED_FIELD_TYPES = Set.of(
|
||||
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "Enum", "List");
|
||||
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "List");
|
||||
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
|
||||
"inclusionai/ling-3.0-flash:free",
|
||||
"openai/gpt-oss-20b:free",
|
||||
@@ -58,7 +58,7 @@ public class ScaffoldingController {
|
||||
@PostMapping("/pod")
|
||||
public String scaffoldPod(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
|
||||
String moduleName = req.getOrDefault("moduleName", "dap-was-cus");
|
||||
if (!moduleName.startsWith("dap-was-")) moduleName = "dap-was-" + moduleName;
|
||||
String port = req.getOrDefault("port", "8085");
|
||||
String shortName = moduleName.replace("dap-was-", "").replace("-", "");
|
||||
@@ -83,7 +83,7 @@ public class ScaffoldingController {
|
||||
String description = req.get("description");
|
||||
String group = req.getOrDefault("categoryKey", req.getOrDefault("group", "COMMON"));
|
||||
String routingType = req.getOrDefault("routingType", "HTTP");
|
||||
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
|
||||
String moduleName = req.getOrDefault("moduleName", "dap-was-cus");
|
||||
String author = req.get("author");
|
||||
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
|
||||
String date = req.get("date");
|
||||
@@ -96,7 +96,7 @@ public class ScaffoldingController {
|
||||
List<ToolScaffolder.FieldDefinition> inputFields = parseFields(req.get("inputFields"));
|
||||
List<ToolScaffolder.FieldDefinition> outputFields = parseFields(req.get("outputFields"));
|
||||
if (inputFields.isEmpty()) {
|
||||
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false));
|
||||
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", List.of("example"), "", false));
|
||||
}
|
||||
ToolScaffolder.ToolDefinitionOptions definitionOptions = new ToolScaffolder.ToolDefinitionOptions(
|
||||
req.get("functionDescription"),
|
||||
@@ -124,7 +124,7 @@ public class ScaffoldingController {
|
||||
throw new IllegalArgumentException("UseCase name must be PascalCase.");
|
||||
}
|
||||
String moduleName = request.moduleName() == null || request.moduleName().isBlank()
|
||||
? "dap-was-oth" : request.moduleName().trim();
|
||||
? "dap-was-cus" : request.moduleName().trim();
|
||||
String author = request.author() == null || request.author().isBlank()
|
||||
? System.getProperty("user.name") : request.author().trim();
|
||||
String date = request.date() == null || request.date().isBlank()
|
||||
@@ -155,6 +155,21 @@ public class ScaffoldingController {
|
||||
.sorted().toList();
|
||||
}
|
||||
|
||||
@GetMapping("/categories")
|
||||
public List<String> listCategories(@RequestParam String moduleName) {
|
||||
if (moduleName == null || !moduleName.matches("^dap-was-[a-z0-9-]+$")) {
|
||||
throw new IllegalArgumentException("Invalid target module.");
|
||||
}
|
||||
String sourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
if (sourceDir == null || sourceDir.isBlank()) sourceDir = System.getProperty("user.dir");
|
||||
File bizDir = new File(sourceDir, moduleName + "/src/main/java/io/shinhanlife/dap/mcc/biz");
|
||||
File[] dirs = bizDir.listFiles(File::isDirectory);
|
||||
if (dirs == null) return List.of();
|
||||
return Arrays.stream(dirs).map(File::getName)
|
||||
.filter(name -> name.matches("^[a-z0-9]{3}$"))
|
||||
.sorted().toList();
|
||||
}
|
||||
|
||||
@PostMapping("/field-draft")
|
||||
public ResponseEntity<?> generateFieldDraft(@RequestBody Map<String, String> req) {
|
||||
String description = req.getOrDefault("description", "").trim();
|
||||
@@ -172,9 +187,10 @@ public class ScaffoldingController {
|
||||
Generate Java DTO fields for an MCP tool.
|
||||
Return JSON only. Do not add Markdown, explanations, or code fences.
|
||||
The response must have this exact shape:
|
||||
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List.
|
||||
Enum fields must include enumValues. List fields must include itemType; use Object plus itemFields for object lists.
|
||||
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, List.
|
||||
Finite values should be enforced by populating enumValues on standard types. List fields must include itemType; use Object plus itemFields for object lists.
|
||||
If applicable, provide a regex for pattern.
|
||||
Generate fields only for the requested target: %s.
|
||||
For OUTPUT fields, include resultCode and resultMessage when appropriate.
|
||||
Keep field names valid Java camelCase identifiers. Generate at most 10 fields.
|
||||
@@ -202,12 +218,13 @@ public class ScaffoldingController {
|
||||
Generate an MCP Tool scaffold from the user request.
|
||||
Return JSON only. Do not add Markdown, explanations, or code fences.
|
||||
The response must have this exact shape:
|
||||
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","examples":["SUCCESS"],"pattern":"","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
categoryKey must be exactly three lowercase letters or digits.
|
||||
routingType must be either HTTP or MCI. httpApiName can contain only letters, digits, hyphens, and underscores.
|
||||
Write every V17 metadata field for its distinct purpose; do not copy the same sentence into all fields.
|
||||
Generate 3 to 10 realistic exampleQueries and concise search tags. Use MCP_TOOL for ownerOrg unless the user names an owner.
|
||||
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List. Enum must include enumValues; List must include itemType and object lists include itemFields.
|
||||
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, List. Finite values should be enforced by populating enumValues. List must include itemType and object lists include itemFields.
|
||||
If applicable, provide a regex for pattern.
|
||||
Keep all field names valid Java camelCase identifiers. Generate at most 10 fields per list.
|
||||
Do not generate interfaceId or clientSystemCode; those must come from a real integration contract.
|
||||
User request: %s
|
||||
@@ -248,12 +265,12 @@ public class ScaffoldingController {
|
||||
File[] files = dir.listFiles(f -> f.isDirectory() && f.getName().startsWith("dap-was-") && !f.getName().equals("dap-was-lib"));
|
||||
|
||||
if (files == null || files.length == 0) {
|
||||
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
|
||||
return List.of("dap-was-cus", "dap-was-sal", "dap-was-pro", "dap-was-sys");
|
||||
}
|
||||
|
||||
return Arrays.stream(files).map(File::getName).sorted().collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
|
||||
return List.of("dap-was-cus", "dap-was-sal", "dap-was-pro", "dap-was-sys");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +304,8 @@ public class ScaffoldingController {
|
||||
field.name().trim(),
|
||||
field.type() == null ? "String" : field.type().trim(),
|
||||
field.description() == null ? "" : field.description().trim(),
|
||||
field.example() == null ? "" : field.example().trim(),
|
||||
field.examples() == null ? List.of() : field.examples().stream().map(String::trim).toList(),
|
||||
field.pattern() == null ? "" : field.pattern().trim(),
|
||||
field.required(),
|
||||
field.enumValues() == null ? List.of() : field.enumValues(),
|
||||
field.itemType(),
|
||||
@@ -308,9 +326,6 @@ public class ScaffoldingController {
|
||||
}
|
||||
|
||||
private void validateStructuredField(ToolScaffolder.FieldDefinition field) {
|
||||
if ("Enum".equals(field.type()) && field.enumValues().isEmpty()) {
|
||||
throw new IllegalArgumentException("Enum field needs enumValues: " + field.name());
|
||||
}
|
||||
if ("List".equals(field.type()) && (field.itemType() == null || field.itemType().isBlank())) {
|
||||
throw new IllegalArgumentException("List field needs itemType: " + field.name());
|
||||
}
|
||||
@@ -375,8 +390,7 @@ public class ScaffoldingController {
|
||||
return chatClientBuilder.build().prompt()
|
||||
.user(prompt)
|
||||
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
|
||||
.model(resolveModel(requestedModel))
|
||||
.build())
|
||||
.model(resolveModel(requestedModel)).build())
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Gateway 내부 MCP 목록 조회와 Report 서비스의 Excel API 프록시를 담당한다. */
|
||||
@RestController
|
||||
public class ToolReportProxyController {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String reportServiceUrl;
|
||||
private final McpRouterController mcpRouterController;
|
||||
|
||||
public ToolReportProxyController(
|
||||
RestClient.Builder restClientBuilder,
|
||||
McpRouterController mcpRouterController,
|
||||
@Value("${report.service-url:http://127.0.0.1:8092}") String reportServiceUrl) {
|
||||
this.restClient = restClientBuilder.build();
|
||||
this.mcpRouterController = mcpRouterController;
|
||||
this.reportServiceUrl = reportServiceUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
/** Tool 목록은 Report 서비스나 로컬 소스가 아니라 Gateway의 MCP Registry에서만 조회한다. */
|
||||
@GetMapping(value = "/report/api/report-tools", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<ReportToolSummary> tools() {
|
||||
return activeTools().stream().map(ReportToolSummary::from).toList();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/report/api/tool-reports/excel",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> excel(@RequestBody Map<String, Object> request) {
|
||||
validateMcpSelection(request);
|
||||
ResponseEntity<byte[]> response = restClient.post()
|
||||
.uri(reportServiceUrl + "/api/tool-reports/excel")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(byte[].class);
|
||||
return copy(response);
|
||||
}
|
||||
|
||||
private void validateMcpSelection(Map<String, Object> request) {
|
||||
Object rawNames = request.get("toolNames");
|
||||
if (!(rawNames instanceof List<?> names) || names.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "toolNames must contain at least one tool");
|
||||
}
|
||||
Set<String> registeredNames = activeTools().stream()
|
||||
.map(ToolMetadata::getName)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> unknown = new LinkedHashSet<>();
|
||||
names.stream().map(String::valueOf)
|
||||
.filter(name -> !registeredNames.contains(name))
|
||||
.forEach(unknown::add);
|
||||
if (!unknown.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"MCP에 등록되지 않은 툴이 포함되어 있습니다: " + unknown);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ToolMetadata> activeTools() {
|
||||
ResponseEntity<JsonRpcResponse> response = mcpRouterController.listTools(null);
|
||||
JsonRpcResponse body = response.getBody();
|
||||
if (body == null || !(body.getResult() instanceof Map<?, ?> result)
|
||||
|| !(result.get("tools") instanceof List<?> tools)) {
|
||||
throw new IllegalStateException("MCP tools/list response does not contain result.tools");
|
||||
}
|
||||
return tools.stream().map(item -> {
|
||||
if (!(item instanceof ToolMetadata metadata)) {
|
||||
throw new IllegalStateException("MCP tools/list contains an invalid tool entry");
|
||||
}
|
||||
return metadata;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> copy(ResponseEntity<byte[]> response) {
|
||||
ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode());
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
if (contentType != null) builder.contentType(contentType);
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
if (disposition != null) builder.header(HttpHeaders.CONTENT_DISPOSITION, disposition);
|
||||
return builder.body(response.getBody());
|
||||
}
|
||||
|
||||
public record ReportToolSummary(
|
||||
String name,
|
||||
String title,
|
||||
String description,
|
||||
String categoryKey,
|
||||
String mappingId,
|
||||
boolean register,
|
||||
boolean requiresApproval,
|
||||
boolean readOnlyHint,
|
||||
boolean destructiveHint,
|
||||
boolean idempotentHint,
|
||||
boolean openWorldHint,
|
||||
String requestType,
|
||||
String responseType,
|
||||
String useCaseClass,
|
||||
String sourceFile,
|
||||
String inputSchemaResource,
|
||||
String outputSchemaResource) {
|
||||
|
||||
static ReportToolSummary from(ToolMetadata tool) {
|
||||
String name = tool.getName() == null || tool.getName().isBlank() ? tool.getUid() : tool.getName();
|
||||
String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank()
|
||||
? name : tool.getDisplayName();
|
||||
return new ReportToolSummary(
|
||||
name, title, value(tool.getDescription()), value(tool.getCategoryKey()),
|
||||
value(tool.getMciServiceId()), Boolean.TRUE.equals(tool.getIsRegistered()),
|
||||
Boolean.TRUE.equals(tool.getRequiresApproval()), Boolean.TRUE.equals(tool.getReadOnlyHint()),
|
||||
Boolean.TRUE.equals(tool.getDestructiveHint()), Boolean.TRUE.equals(tool.getIdempotentHint()),
|
||||
Boolean.TRUE.equals(tool.getOpenWorldHint()), "", "", "", "", "", "");
|
||||
}
|
||||
|
||||
private static String value(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package io.shinhanlife.dap.mcg.redis;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.redis
|
||||
* @className RedisToolTraceService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.mcg.security.McpRequestContext;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class RedisToolTraceService {
|
||||
private static final Logger log = LoggerFactory.getLogger(RedisToolTraceService.class);
|
||||
private static final String KEY_PREFIX = "mcp:request:";
|
||||
private static final String RECENT_KEY = "mcp:request:recent";
|
||||
|
||||
private final McpGatewayProperties properties;
|
||||
private final ObjectProvider<StringRedisTemplate> redisProvider;
|
||||
private final ObjectMapper json;
|
||||
private final McpMonitorEventService monitorEvents;
|
||||
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
|
||||
|
||||
public RedisToolTraceService(McpGatewayProperties properties,
|
||||
ObjectProvider<StringRedisTemplate> redisProvider,
|
||||
ObjectMapper json,
|
||||
McpMonitorEventService monitorEvents) {
|
||||
this.properties = properties;
|
||||
this.redisProvider = redisProvider;
|
||||
this.json = json;
|
||||
this.monitorEvents = monitorEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent request has entered the MCP tool gateway.
|
||||
*/
|
||||
public void started(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments) {
|
||||
save(context, metadata, arguments, "STARTED", 0, "", 0, 0, 0,
|
||||
"Agent request entered MCP", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP is attempting to call the target Tool server.
|
||||
*/
|
||||
public void attemptStarted(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
int attempt, int maxAttempts) {
|
||||
save(context, metadata, arguments, "ATTEMPTING", 0, "", attempt, maxAttempts, 0,
|
||||
"Calling tool server", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP will retry the Tool call after backoff.
|
||||
*/
|
||||
public void retryWaiting(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
int failedAttempt, int maxAttempts, long backoffMillis, String failureType) {
|
||||
save(context, metadata, arguments, "RETRY_WAITING", 0, failureType, failedAttempt, maxAttempts, backoffMillis,
|
||||
"Retry will be attempted after backoff", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution has finished.
|
||||
*/
|
||||
public void finished(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
long elapsedMillis, boolean success, String failureType, String responseText) {
|
||||
save(context, metadata, arguments, success ? "SUCCESS" : "FAILED", elapsedMillis, failureType, 0, 0, 0,
|
||||
success ? "Tool call completed" : "Tool call failed", responseText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent live trace entries. One current entry is kept per request id.
|
||||
*/
|
||||
public List<String> recent() {
|
||||
if (!properties.redisTraceEnabled()) {
|
||||
return List.of("Redis trace is disabled. Set MCP_REDIS_TRACE_ENABLED=true.");
|
||||
}
|
||||
StringRedisTemplate redis = redisProvider.getIfAvailable();
|
||||
if (redis == null) {
|
||||
return List.of("RedisTemplate is not available.");
|
||||
}
|
||||
try {
|
||||
List<String> requestIds = redis.opsForList().range(RECENT_KEY, 0, 49);
|
||||
if (requestIds == null || requestIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return requestIds.stream()
|
||||
.map(requestId -> redis.opsForValue().get(KEY_PREFIX + requestId))
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.toList();
|
||||
} catch (Exception error) {
|
||||
log.warn("Redis trace read failed. message={}", error.getMessage());
|
||||
return List.of("Redis trace read failed: " + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical accumulation is intentionally disabled.
|
||||
*
|
||||
* The MCP monitor is used to observe the current Agent request flow only,
|
||||
* so this method returns the same live entries as recent().
|
||||
*/
|
||||
public List<String> history() {
|
||||
return recent();
|
||||
}
|
||||
|
||||
private void save(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, String status,
|
||||
long elapsedMillis, String failureType, int attempt, int maxAttempts,
|
||||
long backoffMillis, String message, String responseText) {
|
||||
String stateKey = stateKey(context, metadata);
|
||||
if (attempt > 0 && maxAttempts > 0) {
|
||||
attemptStates.put(stateKey, new AttemptState(attempt, maxAttempts));
|
||||
}
|
||||
String payload;
|
||||
try {
|
||||
payload = tracePayload(context, metadata, arguments, status, elapsedMillis, failureType, attempt,
|
||||
maxAttempts, backoffMillis, message, responseText);
|
||||
monitorEvents.publish(payload);
|
||||
if ("SUCCESS".equals(status) || "FAILED".equals(status)) {
|
||||
attemptStates.remove(stateKey);
|
||||
}
|
||||
} catch (Exception error) {
|
||||
log.warn("MCP monitor event publish failed. requestId={} tool={} message={}",
|
||||
context.requestId(), metadata.getName(), error.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!properties.redisTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
StringRedisTemplate redis = redisProvider.getIfAvailable();
|
||||
if (redis == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String requestId = context.requestId();
|
||||
Duration ttl = Duration.ofSeconds(properties.redisTraceTtlSeconds());
|
||||
redis.opsForValue().set(KEY_PREFIX + requestId, payload, ttl);
|
||||
redis.opsForList().remove(RECENT_KEY, 0, requestId);
|
||||
redis.opsForList().leftPush(RECENT_KEY, requestId);
|
||||
redis.opsForList().trim(RECENT_KEY, 0, 49);
|
||||
redis.expire(RECENT_KEY, ttl);
|
||||
} catch (Exception error) {
|
||||
log.warn("Redis trace save failed. requestId={} tool={} message={}",
|
||||
context.requestId(), metadata.getName(), error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
String tracePayload(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, String status,
|
||||
long elapsedMillis, String failureType, int attempt, int maxAttempts,
|
||||
long backoffMillis, String message, String responseText) throws Exception {
|
||||
AttemptState state = attemptStates.get(stateKey(context, metadata));
|
||||
int displayAttempt = attempt > 0 ? attempt : state == null ? 0 : state.attempt();
|
||||
int displayMaxAttempts = maxAttempts > 0 ? maxAttempts : state == null ? 0 : state.maxAttempts();
|
||||
int retryCount = Math.max(0, displayAttempt - 1);
|
||||
Map<String, Object> trace = new LinkedHashMap<>();
|
||||
trace.put("requestId", context.requestId());
|
||||
trace.put("traceGroupId", context.traceGroupId());
|
||||
trace.put("agentId", context.agentId());
|
||||
trace.put("userId", context.userId());
|
||||
trace.put("clientAddress", context.clientAddress());
|
||||
trace.put("toolName", metadata.getName());
|
||||
trace.put("operationType", metadata.getOperationType() != null ? metadata.getOperationType().name() : "READ");
|
||||
trace.put("status", status);
|
||||
trace.put("message", message == null ? "" : message);
|
||||
trace.put("failureType", failureType == null ? "" : failureType);
|
||||
trace.put("attempt", displayAttempt);
|
||||
trace.put("maxAttempts", displayMaxAttempts);
|
||||
trace.put("retryCount", retryCount);
|
||||
trace.put("backoffMillis", backoffMillis);
|
||||
trace.put("elapsedMillis", elapsedMillis);
|
||||
trace.put("idempotencyKeyHash", hashText(arguments.path("idempotencyKey").asText("")));
|
||||
|
||||
List<String> argNames = new ArrayList<>();
|
||||
arguments.fieldNames().forEachRemaining(argNames::add);
|
||||
trace.put("argumentNames", argNames);
|
||||
|
||||
trace.put("arguments", arguments.toString());
|
||||
trace.put("responseSummary", responseSummary(responseText));
|
||||
trace.put("timestamp", Instant.now().toString());
|
||||
return json.writeValueAsString(trace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis Trace에는 Tool 응답 원문을 저장하지 않고 size/hash/상태/카운트만 저장합니다.
|
||||
*/
|
||||
Map<String, Object> responseSummary(String responseText) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
if (responseText == null || responseText.isBlank()) {
|
||||
summary.put("bytes", 0);
|
||||
summary.put("sha256", "");
|
||||
return summary;
|
||||
}
|
||||
byte[] bytes = responseText.getBytes(StandardCharsets.UTF_8);
|
||||
summary.put("bytes", bytes.length);
|
||||
summary.put("sha256", sha256(bytes));
|
||||
try {
|
||||
JsonNode root = json.readTree(responseText);
|
||||
summary.put("success", root.path("success").asBoolean(false));
|
||||
summary.put("status", root.path("status").asText(""));
|
||||
summary.put("toolName", root.path("toolName").asText(""));
|
||||
summary.put("pageSize", root.path("pageSize").asInt(0));
|
||||
summary.put("pageCount", root.path("pageCount").asInt(0));
|
||||
summary.put("returnedCount", root.path("returnedCount").asInt(0));
|
||||
summary.put("totalCount", root.path("totalCount").asLong(0));
|
||||
summary.put("hasMore", root.path("hasMore").asBoolean(false));
|
||||
summary.put("nextCursor", root.path("nextCursor").asText(""));
|
||||
summary.put("message", root.path("message").asText(""));
|
||||
summary.put("resultRef", root.path("resultRef").asText(""));
|
||||
} catch (Exception ignored) {
|
||||
summary.put("parseable", false);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private String sha256(byte[] bytes) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (Exception error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String hashText(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return sha256(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String stateKey(McpRequestContext context, ToolMetadata metadata) {
|
||||
return context.requestId() + ":" + metadata.getName();
|
||||
}
|
||||
|
||||
private record AttemptState(int attempt, int maxAttempts) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.shinhanlife.dap.mcg.registry;
|
||||
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.registry
|
||||
* @className InMemoryRegistryService
|
||||
* @description AX HUB 인메모리 툴 레지스트리
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InMemoryRegistryService {
|
||||
|
||||
private final Map<String, ToolMetadata> toolCache = new ConcurrentHashMap<>();
|
||||
private static final long DEFAULT_TTL_MILLIS = 45000; // 45초
|
||||
|
||||
/**
|
||||
* 툴 등록 및 갱신
|
||||
*/
|
||||
public void saveTool(ToolMetadata meta) {
|
||||
toolCache.put(meta.getUid(), meta);
|
||||
log.info(" [InMemoryRegistry] 툴 등록 완료: {}", meta.getUid());
|
||||
}
|
||||
|
||||
public List<String> getAvailablePods(String uid) {
|
||||
ToolMetadata tool = getTool(uid);
|
||||
if (tool != null && tool.getPodUrl() != null) {
|
||||
return List.of(tool.getPodUrl());
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 시 툴 정보 조회
|
||||
*/
|
||||
public ToolMetadata getTool(String uid) {
|
||||
return toolCache.get(uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴 이름으로 정보 조회
|
||||
*/
|
||||
public ToolMetadata getToolByName(String name) {
|
||||
for (ToolMetadata tool : toolCache.values()) {
|
||||
if (name.equals(tool.getName())) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 모든 활성 툴 목록 조회
|
||||
*/
|
||||
public List<ToolMetadata> getAllTools() {
|
||||
return List.copyOf(toolCache.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴 명시적 제거 (Deregister)
|
||||
*/
|
||||
public void removeTool(String uid) {
|
||||
toolCache.remove(uid);
|
||||
log.info(" [InMemoryRegistry] 툴 삭제 완료: {}", uid);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package io.shinhanlife.dap.mcg.registry;
|
||||
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.registry
|
||||
* @className RedisRegistryService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RedisRegistryService {
|
||||
|
||||
private final RedisTemplate<String, ToolMetadata> redisTemplate;
|
||||
private static final String KEY_PREFIX = "mcp:tool:";
|
||||
private static final Duration DEFAULT_TTL = Duration.ofSeconds(45); // 실무 하트비트 주기 반영
|
||||
|
||||
/**
|
||||
* 툴 등록 및 갱신 (TTL 기반으로 60초 뒤 자동 만료)
|
||||
*/
|
||||
public void saveTool(ToolMetadata meta) {
|
||||
String key = KEY_PREFIX + meta.getUid();
|
||||
meta.setLastHeartbeat(System.currentTimeMillis());
|
||||
redisTemplate.opsForValue().set(key, meta, DEFAULT_TTL);
|
||||
log.info(" [RedisRegistry] 툴 등록 완료: {}", meta.getUid());
|
||||
}
|
||||
|
||||
/**
|
||||
* 하트비트 갱신 (TTL 초기화)
|
||||
*/
|
||||
public boolean refreshHeartbeat(String uid) {
|
||||
String key = KEY_PREFIX + uid;
|
||||
Boolean exists = redisTemplate.expire(key, DEFAULT_TTL);
|
||||
if (Boolean.TRUE.equals(exists)) {
|
||||
log.debug(" [RedisRegistry] 하트비트 갱신: {}", uid);
|
||||
return true;
|
||||
} else {
|
||||
log.warn(" [RedisRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", uid);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public List<String> getAvailablePods(String uid) {
|
||||
ToolMetadata tool = getTool(uid);
|
||||
|
||||
if (tool != null && tool.getPodUrl() != null) {
|
||||
return List.of(tool.getPodUrl());
|
||||
}
|
||||
|
||||
return List.of();
|
||||
}
|
||||
/**
|
||||
* 실행 시 툴 정보 조회 (Tool Execution 시 참조)
|
||||
*/
|
||||
public ToolMetadata getTool(String uid) {
|
||||
return redisTemplate.opsForValue().get(KEY_PREFIX + uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴 이름으로 정보 조회
|
||||
*/
|
||||
public ToolMetadata getToolByName(String name) {
|
||||
List<ToolMetadata> allTools = getAllTools();
|
||||
for (ToolMetadata tool : allTools) {
|
||||
if (name.equals(tool.getName())) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 모든 활성 툴 목록 조회
|
||||
*/
|
||||
public List<ToolMetadata> getAllTools() {
|
||||
Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ToolMetadata> tools = redisTemplate.opsForValue().multiGet(keys);
|
||||
return tools != null ? tools.stream().filter(Objects::nonNull).collect(Collectors.toList()) : List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴 명시적 제거 (Deregister)
|
||||
*/
|
||||
public void removeTool(String uid) {
|
||||
redisTemplate.delete(KEY_PREFIX + uid);
|
||||
log.info(" [RedisRegistry] 툴 삭제 완료: {}", uid);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import io.shinhanlife.dap.mcg.audit.AuditLogService;
|
||||
import io.shinhanlife.dap.mcg.resilience.CircuitBreaker;
|
||||
import io.shinhanlife.dap.mcg.resilience.CircuitBreakerService;
|
||||
import io.shinhanlife.dap.mcg.security.ToolAuthorizationService;
|
||||
import io.shinhanlife.dap.mcg.redis.RedisToolTraceService;
|
||||
import io.shinhanlife.dap.mcg.trace.InMemoryToolTraceService;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.util.Map;
|
||||
@@ -62,7 +62,7 @@ public class ExecuteService {
|
||||
private final AuditLogService auditLogService;
|
||||
private final CircuitBreakerService circuitBreakerService;
|
||||
private final ToolAuthorizationService authorizationService;
|
||||
private final RedisToolTraceService redisTrace;
|
||||
private final InMemoryToolTraceService traceService;
|
||||
private final McpGatewayProperties properties;
|
||||
private final LargeToolResponseService largeResponses;
|
||||
private final PaginationRequestValidator paginationValidator;
|
||||
@@ -79,7 +79,7 @@ public class ExecuteService {
|
||||
AuditLogService auditLogService,
|
||||
CircuitBreakerService circuitBreakerService,
|
||||
ToolAuthorizationService authorizationService,
|
||||
RedisToolTraceService redisTrace,
|
||||
InMemoryToolTraceService traceService,
|
||||
McpGatewayProperties properties,
|
||||
LargeToolResponseService largeResponses,
|
||||
PaginationRequestValidator paginationValidator,
|
||||
@@ -94,7 +94,7 @@ public class ExecuteService {
|
||||
this.auditLogService = auditLogService;
|
||||
this.circuitBreakerService = circuitBreakerService;
|
||||
this.authorizationService = authorizationService;
|
||||
this.redisTrace = redisTrace;
|
||||
this.traceService = traceService;
|
||||
this.properties = properties;
|
||||
this.largeResponses = largeResponses;
|
||||
this.paginationValidator = paginationValidator;
|
||||
@@ -133,7 +133,7 @@ public class ExecuteService {
|
||||
|
||||
long startedAt = System.nanoTime();
|
||||
auditLogService.toolStarted(context, toolName, argumentsNode);
|
||||
redisTrace.started(context, metadata, argumentsNode);
|
||||
traceService.started(context, metadata, argumentsNode);
|
||||
|
||||
try {
|
||||
authorizationService.authorize(context, metadata);
|
||||
@@ -174,13 +174,13 @@ public class ExecuteService {
|
||||
finalResult.put("original_size", originalSize);
|
||||
|
||||
auditLogService.toolFinished(context, metadata.getName(), elapsedMillis, true, "");
|
||||
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
|
||||
traceService.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
|
||||
|
||||
return finalResult;
|
||||
} catch (ToolExecutionException error) {
|
||||
long elapsedMillis = elapsedMillis(startedAt);
|
||||
auditLogService.toolFinished(context, toolName, elapsedMillis, false, error.failureType().name());
|
||||
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
|
||||
traceService.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
|
||||
|
||||
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
|
||||
errorResult.put("status", "error");
|
||||
@@ -194,7 +194,7 @@ public class ExecuteService {
|
||||
} catch (Exception error) {
|
||||
long elapsedMillis = elapsedMillis(startedAt);
|
||||
auditLogService.toolFinished(context, toolName, elapsedMillis, false, FailureType.INTERNAL_ERROR.name());
|
||||
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
|
||||
traceService.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
|
||||
|
||||
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
|
||||
errorResult.put("status", "error");
|
||||
@@ -225,7 +225,7 @@ public class ExecuteService {
|
||||
ToolExecutionException lastError = null;
|
||||
for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) {
|
||||
try {
|
||||
redisTrace.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts());
|
||||
traceService.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts());
|
||||
circuitBreaker.beforeCall();
|
||||
Object result = executeOnce(context, metadata, arguments, payload);
|
||||
circuitBreaker.recordSuccess();
|
||||
@@ -237,7 +237,7 @@ public class ExecuteService {
|
||||
throw error;
|
||||
}
|
||||
long backoffMillis = retryPolicy.backoffMillis(attempt);
|
||||
redisTrace.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis,
|
||||
traceService.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis,
|
||||
error.failureType().name());
|
||||
sleepBeforeRetry(metadata.getName(), attempt, backoffMillis, error.failureType());
|
||||
}
|
||||
|
||||
@@ -2,34 +2,29 @@ package io.shinhanlife.dap.mcg.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.service
|
||||
* @className KillSwitchService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @description AX HUB 시스템 킬 스위치 서비스 (인메모리)
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KillSwitchService {
|
||||
|
||||
// 기본 제공되는 StringRedisTemplate 사용
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
// 인메모리 맵으로 변경
|
||||
private final Map<String, String> killSwitchMap = new ConcurrentHashMap<>();
|
||||
|
||||
public void checkAgent(String tenantId) {
|
||||
String key = "mcp:kill:agent:" + tenantId;
|
||||
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
|
||||
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
|
||||
log.warn(" [KillSwitch] 차단된 에이전트 접근 시도: {}", tenantId);
|
||||
throw new SecurityException(" 비상 차단: 해당 테넌트(" + tenantId + ")의 접근이 관리자에 의해 차단되었습니다.");
|
||||
}
|
||||
@@ -39,7 +34,7 @@ public class KillSwitchService {
|
||||
if (toolName == null || toolName.isBlank()) return;
|
||||
|
||||
String key = "mcp:kill:tool:" + toolName;
|
||||
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
|
||||
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
|
||||
log.warn(" [KillSwitch] 차단된 툴 실행 시도: {}", toolName);
|
||||
throw new SecurityException(" 비상 차단: 해당 툴(" + toolName + ")의 실행이 관리자에 의해 차단되었습니다.");
|
||||
}
|
||||
@@ -49,7 +44,7 @@ public class KillSwitchService {
|
||||
if (integrationType == null || integrationType.isBlank()) return;
|
||||
|
||||
String key = "mcp:kill:route:" + integrationType;
|
||||
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
|
||||
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
|
||||
log.warn(" [KillSwitch] 차단된 라우트 통신 시도: {}", integrationType);
|
||||
throw new SecurityException(" 비상 차단: 해당 레거시 라우트(" + integrationType + ")로의 통신이 관리자에 의해 차단되었습니다.");
|
||||
}
|
||||
@@ -58,14 +53,14 @@ public class KillSwitchService {
|
||||
// 관리자 API용 토글 메서드
|
||||
public void toggleKillSwitch(String type, String target, boolean state) {
|
||||
String key = "mcp:kill:" + type + ":" + target;
|
||||
stringRedisTemplate.opsForValue().set(key, String.valueOf(state));
|
||||
killSwitchMap.put(key, String.valueOf(state));
|
||||
log.info(" [KillSwitch] 상태 변경: {} -> {} (차단 상태: {})", type, target, state);
|
||||
}
|
||||
|
||||
// 관리자 API용 삭제 메서드 (Redis 키 자체를 완전 삭제)
|
||||
// 관리자 API용 삭제 메서드 (맵 항목 삭제)
|
||||
public void removeKillSwitch(String type, String target) {
|
||||
String key = "mcp:kill:" + type + ":" + target;
|
||||
stringRedisTemplate.delete(key);
|
||||
killSwitchMap.remove(key);
|
||||
log.info(" [KillSwitch] 차단 키 완전 삭제: {} -> {}", type, target);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package io.shinhanlife.dap.mcg.service;
|
||||
|
||||
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -14,14 +14,14 @@ import java.util.Map;
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.service
|
||||
* @className ToolPlanner
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @description AX HUB ?<3F>스??처리 ?<3F>래??
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- 개정?<3F>력 ----------
|
||||
* ?<3F>정?? ?<3F>정?? ?<3F>정?<3F>용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
* 2026.09.01 0986406 최초?<3F>성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -30,30 +30,30 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class ToolPlanner {
|
||||
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
private final InMemoryRegistryService InMemoryRegistryService;
|
||||
private final SecurityProperties securityProperties;
|
||||
private final GatewayFallbackProperties fallbackProperties;
|
||||
|
||||
/**
|
||||
* 요청(payload)을 분석하여 실행해야 할 Tool의 계획을 생성합니다.
|
||||
* ?<3F>청(payload)??분석?<3F>여 ?<3F>행?<3F>야 ??Tool??계획???<3F>성?<3F>니??
|
||||
*/
|
||||
public Object createPlan(Map<String, Object> payload, String tenantId) {
|
||||
log.info(" [Planner] 요청 분석 및 실행 계획 수립 시작");
|
||||
log.info(" [Planner] ?<3F>청 분석 <EFBFBD>??<3F>행 계획 ?<3F>립 ?<3F>작");
|
||||
|
||||
// 1. 요청에서 호출하려는 툴 이름 추출 (JSON-RPC params.name)
|
||||
// 1. ?<3F>청?<3F>서 ?<3F>출?<3F>려?????<3F>름 추출 (JSON-RPC params.name)
|
||||
Map<String, Object> params = (Map<String, Object>) payload.get("params");
|
||||
String toolName = params != null ? (String) params.get("name") : null;
|
||||
|
||||
if (toolName == null || toolName.isEmpty()) {
|
||||
throw new IllegalArgumentException("요청에 toolName이 포함되어 있지 않습니다.");
|
||||
throw new IllegalArgumentException("?<3F>청??toolName???<3F>함?<3F>어 ?<3F><>? ?<3F>습?<3F>다.");
|
||||
}
|
||||
|
||||
// 2. RedisRegistry에서 해당 툴의 메타데이터 조회
|
||||
// (실제로는 이 메타데이터가 실행 계획의 핵심이 됩니다)
|
||||
var toolMetadata = redisRegistryService.getToolByName(toolName);
|
||||
// 2. RedisRegistry?<3F>서 ?<3F>당 ?<3F>의 메<>??<3F>이??조회
|
||||
// (?<3F>제로는 ??메<>??<3F>이?<3F><>? ?<3F>행 계획???<3F>심???<3F>니??
|
||||
var toolMetadata = InMemoryRegistryService.getToolByName(toolName);
|
||||
|
||||
if (toolMetadata == null) {
|
||||
log.warn(" [Planner] 등록되지 않은 툴 요청: {}. Fallback 라우팅 규칙을 확인합니다.", toolName);
|
||||
log.warn(" [Planner] ?<3F>록?<3F><>? ?<3F><>? ???<3F>청: {}. Fallback ?<3F>우??규칙???<3F>인?<3F>니??", toolName);
|
||||
|
||||
String fallbackPodUrl = fallbackProperties.getDefaultUrl();
|
||||
if (fallbackProperties.getRoutes() != null) {
|
||||
@@ -66,11 +66,11 @@ public class ToolPlanner {
|
||||
}
|
||||
|
||||
if (fallbackPodUrl == null || fallbackPodUrl.isEmpty()) {
|
||||
log.error(" [Planner] Fallback 라우팅 대상이 아닙니다. 툴: {}", toolName);
|
||||
throw new RuntimeException("해당 툴(" + toolName + ")이 레지스트리에 존재하지 않습니다.");
|
||||
log.error(" [Planner] Fallback ?<3F>우???<3F>?<3F>이 ?<3F>닙?<3F>다. ?? {}", toolName);
|
||||
throw new RuntimeException("?<3F>당 ??" + toolName + ")???<3F><>??<3F>트리에 존재?<3F><>? ?<3F>습?<3F>다.");
|
||||
}
|
||||
|
||||
log.info(" [Planner] Fallback 라우팅 매칭됨: {} -> {}", toolName, fallbackPodUrl);
|
||||
log.info(" [Planner] Fallback ?<3F>우??매칭?? {} -> {}", toolName, fallbackPodUrl);
|
||||
|
||||
toolMetadata = ToolMetadata.builder()
|
||||
.uid(toolName)
|
||||
@@ -80,26 +80,26 @@ public class ToolPlanner {
|
||||
.build();
|
||||
}
|
||||
|
||||
// 2-1. [신규] 도메인 그룹핑 기반 권한 검증
|
||||
// 2-1. [?<3F>규] ?<3F>메??그룹??기반 권한 검<EFBFBD>?
|
||||
if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) {
|
||||
String normalizedTenantId = tenantId.toLowerCase();
|
||||
List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId);
|
||||
|
||||
// 만약 대소문자 변환 후에도 없으면 원래 값으로 한 번 더 시도 (하위 호환성)
|
||||
// 만약 ?<3F>?<3F>문??변???<3F>에???<3F>으<EFBFBD>??<3F>래 값으<EAB092>???<3F>????<3F>도 (?<3F>위 ?<3F>환??
|
||||
if (allowedDomains == null) {
|
||||
allowedDomains = securityProperties.getTenantDomains().get(tenantId);
|
||||
}
|
||||
|
||||
if (allowedDomains == null ||
|
||||
(!allowedDomains.contains("ALL") && !allowedDomains.contains(toolMetadata.getCategoryKey()))) {
|
||||
log.warn(" [Planner] 권한 거부 - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey());
|
||||
throw new SecurityException("해당 도메인(" + toolMetadata.getCategoryKey() + ")의 툴을 실행할 권한이 없습니다.");
|
||||
log.warn(" [Planner] 권한 거<EFBFBD>? - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey());
|
||||
throw new SecurityException("?<3F>당 ?<3F>메??" + toolMetadata.getCategoryKey() + ")???<3F>을 ?<3F>행??권한???<3F>습?<3F>다.");
|
||||
}
|
||||
}
|
||||
|
||||
log.info(" [Planner] 툴 '{}'에 대한 실행 계획 수립 완료", toolName);
|
||||
log.info(" [Planner] ??'{}'???<3F>???<3F>행 계획 ?<3F>립 ?<3F>료", toolName);
|
||||
|
||||
// 3. 수립된 계획(툴 메타데이터) 반환
|
||||
// 3. ?<3F>립??계획(??메<>??<3F>이?? 반환
|
||||
return toolMetadata;
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,15 @@ public class DynamicMcpServerManager {
|
||||
getOrCreateServer("common");
|
||||
getOrCreateServer("hr");
|
||||
getOrCreateServer("payment");
|
||||
// Tool category names are not Pod names. Keep existing external category
|
||||
// endpoints available while the SMS/OTH Pods transition to SAL/CUS.
|
||||
getOrCreateServer("sms");
|
||||
getOrCreateServer("email");
|
||||
getOrCreateServer("oth");
|
||||
getOrCreateServer("sal");
|
||||
getOrCreateServer("email");
|
||||
getOrCreateServer("cus");
|
||||
getOrCreateServer("pro");
|
||||
getOrCreateServer("sys");
|
||||
getOrCreateServer("sample");
|
||||
getOrCreateServer("smp"); // smp 카테고리도 기본적으로 항상 열어두도록 추가
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package io.shinhanlife.dap.mcg.sync;
|
||||
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
|
||||
import io.modelcontextprotocol.server.McpSyncServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -38,18 +38,18 @@ public class RegistryMcpToolSynchronizer {
|
||||
|
||||
private final ObjectProvider<McpSyncServer> mcpServerProvider;
|
||||
private final DynamicMcpServerManager dynamicMcpServerManager;
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
private final InMemoryRegistryService InMemoryRegistryService;
|
||||
private final RegistryMcpToolSpecificationFactory specificationFactory;
|
||||
private final Set<String> managedToolNames = new LinkedHashSet<>();
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
public RegistryMcpToolSynchronizer(ObjectProvider<McpSyncServer> mcpServerProvider,
|
||||
DynamicMcpServerManager dynamicMcpServerManager,
|
||||
RedisRegistryService redisRegistryService,
|
||||
InMemoryRegistryService InMemoryRegistryService,
|
||||
RegistryMcpToolSpecificationFactory specificationFactory) {
|
||||
this.mcpServerProvider = mcpServerProvider;
|
||||
this.dynamicMcpServerManager = dynamicMcpServerManager;
|
||||
this.redisRegistryService = redisRegistryService;
|
||||
this.InMemoryRegistryService = InMemoryRegistryService;
|
||||
this.specificationFactory = specificationFactory;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ public class RegistryMcpToolSynchronizer {
|
||||
public void synchronize() {
|
||||
lock.lock();
|
||||
try {
|
||||
List<ToolMetadata> activeEntries = redisRegistryService.getAllTools()
|
||||
List<ToolMetadata> activeEntries = InMemoryRegistryService.getAllTools()
|
||||
.stream().filter(ToolMetadata::getVisible).collect(Collectors.toList());
|
||||
|
||||
// 1. Dynamic MCP Server 동기화 (카테고리별)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package io.shinhanlife.dap.mcg.trace;
|
||||
|
||||
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.mcg.security.McpRequestContext;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.trace
|
||||
* @className InMemoryToolTraceService
|
||||
* @description AX HUB 시스템 인메모리 툴 트레이스 서비스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
*/
|
||||
@Service
|
||||
public class InMemoryToolTraceService {
|
||||
private static final Logger log = LoggerFactory.getLogger(InMemoryToolTraceService.class);
|
||||
|
||||
private final McpGatewayProperties properties;
|
||||
private final ObjectMapper json;
|
||||
private final McpMonitorEventService monitorEvents;
|
||||
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
|
||||
|
||||
// LRU Cache for recent traces
|
||||
private final Map<String, String> recentTraces;
|
||||
|
||||
public InMemoryToolTraceService(McpGatewayProperties properties,
|
||||
ObjectMapper json,
|
||||
McpMonitorEventService monitorEvents) {
|
||||
this.properties = properties;
|
||||
this.json = json;
|
||||
this.monitorEvents = monitorEvents;
|
||||
|
||||
final int maxSize = (int) properties.traceRetentionSize();
|
||||
this.recentTraces = new LinkedHashMap<String, String>(maxSize, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
|
||||
return size() > maxSize;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void started(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments) {
|
||||
save(context, metadata, arguments, "STARTED", 0, "", 0, 0, 0,
|
||||
"Agent request entered MCP", "");
|
||||
}
|
||||
|
||||
public void attemptStarted(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
int attempt, int maxAttempts) {
|
||||
save(context, metadata, arguments, "ATTEMPTING", 0, "", attempt, maxAttempts, 0,
|
||||
"Calling tool server", "");
|
||||
}
|
||||
|
||||
public void retryWaiting(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
int failedAttempt, int maxAttempts, long backoffMillis, String failureType) {
|
||||
save(context, metadata, arguments, "RETRY_WAITING", 0, failureType, failedAttempt, maxAttempts, backoffMillis,
|
||||
"Retry will be attempted after backoff", "");
|
||||
}
|
||||
|
||||
public void finished(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
long elapsedMillis, boolean success, String failureType, String responseText) {
|
||||
save(context, metadata, arguments, success ? "SUCCESS" : "FAILED", elapsedMillis, failureType, 0, 0, 0,
|
||||
success ? "Tool call completed" : "Tool call failed", responseText);
|
||||
}
|
||||
|
||||
public List<String> getRecentTraces(int limit) {
|
||||
if (!properties.traceEnabled()) {
|
||||
return List.of("Trace is disabled. Set traceEnabled=true.");
|
||||
}
|
||||
|
||||
List<String> result = new ArrayList<>();
|
||||
synchronized (recentTraces) {
|
||||
List<String> values = new ArrayList<>(recentTraces.values());
|
||||
// reverse to get latest first
|
||||
for (int i = values.size() - 1; i >= 0 && result.size() < limit; i--) {
|
||||
result.add(values.get(i));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public String getTraceDetail(String requestId) {
|
||||
if (!properties.traceEnabled()) {
|
||||
return "Trace is disabled.";
|
||||
}
|
||||
synchronized (recentTraces) {
|
||||
return recentTraces.get(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private void save(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
|
||||
String state, long elapsedMillis, String failureType, int attempt, int maxAttempts, long backoffMillis,
|
||||
String message, String responseText) {
|
||||
|
||||
if (!properties.traceEnabled()) {
|
||||
return; // trace is off
|
||||
}
|
||||
|
||||
try {
|
||||
AttemptState ast = attemptStates.computeIfAbsent(context.requestId(), k -> new AttemptState(0));
|
||||
if (attempt > 0) ast.currentAttempt = attempt;
|
||||
|
||||
ObjectNode root = json.createObjectNode();
|
||||
root.put("requestId", context.requestId());
|
||||
root.put("agentId", context.agentId());
|
||||
root.put("tenantId", context.traceGroupId());
|
||||
root.put("toolName", metadata.getName());
|
||||
root.put("targetPod", metadata.getPodUrl());
|
||||
root.put("state", state);
|
||||
root.put("timestamp", Instant.now().toString());
|
||||
root.put("elapsedMillis", elapsedMillis);
|
||||
root.put("attempt", ast.currentAttempt);
|
||||
root.put("maxAttempts", maxAttempts > 0 ? maxAttempts : properties.retryMaxAttempts());
|
||||
root.put("backoffMillis", backoffMillis);
|
||||
|
||||
if (failureType != null && !failureType.isEmpty()) {
|
||||
root.put("failureType", failureType);
|
||||
}
|
||||
if (message != null && !message.isEmpty()) {
|
||||
root.put("message", message);
|
||||
}
|
||||
|
||||
// Mask arguments if needed, for simplicity we just put them
|
||||
root.set("arguments", arguments);
|
||||
|
||||
if (responseText != null && !responseText.isEmpty()) {
|
||||
if (responseText.length() > 500) {
|
||||
root.put("responsePreview", responseText.substring(0, 500) + "...");
|
||||
} else {
|
||||
root.put("responsePreview", responseText);
|
||||
}
|
||||
}
|
||||
|
||||
String payload = json.writeValueAsString(root);
|
||||
|
||||
synchronized (recentTraces) {
|
||||
recentTraces.put(context.requestId(), payload);
|
||||
}
|
||||
|
||||
monitorEvents.publish(payload);
|
||||
|
||||
if ("SUCCESS".equals(state) || "FAILED".equals(state)) {
|
||||
attemptStates.remove(context.requestId());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save trace: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static class AttemptState {
|
||||
int currentAttempt;
|
||||
AttemptState(int currentAttempt) {
|
||||
this.currentAttempt = currentAttempt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package io.shinhanlife.dap.mcg.redis;
|
||||
package io.shinhanlife.dap.mcg.trace;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.redis
|
||||
* @package io.shinhanlife.dap.mcg.trace
|
||||
* @className McpMonitorEventService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
@@ -10,10 +10,15 @@
|
||||
mcp:
|
||||
gateway:
|
||||
fallback:
|
||||
default-url: http://was-oth:8084
|
||||
default-url: http://was-cus:8084
|
||||
routes:
|
||||
sms: http://was-sms:8082
|
||||
hr: http://was-oth:8084
|
||||
cus: http://was-cus:8084
|
||||
sal: http://was-sal:8082
|
||||
sms: http://was-sal:8082
|
||||
oth: http://was-cus:8084
|
||||
hr: http://was-cus:8084
|
||||
pro: http://was-pro:8085
|
||||
sys: http://was-sys:8086
|
||||
|
||||
# --- 신한라이프 EAI/MCI 연계 IP 정보 (개발 환경) ---
|
||||
shinhan:
|
||||
|
||||
@@ -37,10 +37,15 @@ mcp:
|
||||
default-url: http://localhost:8084
|
||||
routes:
|
||||
cmm_memo_retriever: http://localhost:8082
|
||||
cus: http://localhost:8084
|
||||
sal: http://localhost:8082
|
||||
sms: http://localhost:8082
|
||||
oth: http://localhost:8084
|
||||
email: http://localhost:8083
|
||||
payment: http://localhost:8085
|
||||
hr: http://localhost:8084
|
||||
pro: http://localhost:8085
|
||||
sys: http://localhost:8086
|
||||
tool:
|
||||
host: localhost
|
||||
|
||||
|
||||
@@ -27,10 +27,16 @@ server:
|
||||
mcp:
|
||||
gateway:
|
||||
fallback:
|
||||
default-url: http://was-oth:8084
|
||||
default-url: http://was-cus:8084
|
||||
routes:
|
||||
sms: http://was-sms:8082
|
||||
hr: http://was-oth:8084
|
||||
cus: http://was-cus:8084
|
||||
sal: http://was-sal:8082
|
||||
# Existing SMS category callers continue to resolve to the SAL Pod.
|
||||
sms: http://was-sal:8082
|
||||
oth: http://was-cus:8084
|
||||
hr: http://was-cus:8084
|
||||
pro: http://was-pro:8085
|
||||
sys: http://was-sys:8086
|
||||
agent-claims-required: false
|
||||
trusted-claims-required: false
|
||||
write-approval-required: false
|
||||
|
||||
@@ -824,6 +824,50 @@
|
||||
<!-- Tool Creation Form -->
|
||||
<div class="tab-pane fade" id="tool" role="tabpanel">
|
||||
<form id="toolForm">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Target Module</label>
|
||||
<select class="form-select" name="moduleName" id="targetModuleSelect" required>
|
||||
<option value="dap-was-cus">dap-was-cus</option>
|
||||
<option value="dap-was-hr">dap-was-hr</option>
|
||||
<option value="dap-was-sal">dap-was-sal</option>
|
||||
<option value="dap-was-pro">dap-was-pro</option>
|
||||
<option value="dap-was-sys">dap-was-sys</option>
|
||||
</select>
|
||||
<div class="input-hint">Destination Pod directory.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 mt-md-0">
|
||||
<label class="form-label">Gateway Registration</label>
|
||||
<select class="form-select" name="register">
|
||||
<option value="true" selected>True (Public)</option>
|
||||
<option value="false">False (Private)</option>
|
||||
</select>
|
||||
<div class="input-hint">Controls dynamic routing exposure.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label mb-1">Domain Category</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5">
|
||||
<select id="domainCategorySelect" class="form-select">
|
||||
<option value="">새 도메인 생성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<input type="text" id="domainCategoryName" class="form-control" name="categoryKey" pattern="^[a-z0-9]{3}$" maxlength="3" minlength="3" placeholder="e.g. cmm (3글자 영소문자)" oninput="this.value = this.value.toLowerCase()" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 mt-md-0">
|
||||
<label class="form-label">Protocol</label>
|
||||
<select class="form-select" name="routingType">
|
||||
<option value="HTTP">HTTP (REST)</option>
|
||||
<option value="MCI">MCI (Legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
@@ -920,26 +964,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Domain Category</label>
|
||||
<input type="text" class="form-control" name="categoryKey" pattern="^[a-z0-9]{3}$" maxlength="3" minlength="3" list="groupList" placeholder="e.g. cmm, smp, pay" oninput="this.value = this.value.toLowerCase()" required>
|
||||
<datalist id="groupList">
|
||||
<option value="smp">smp</option>
|
||||
<option value="cmm">cmm</option>
|
||||
<option value="cus">cus</option>
|
||||
<option value="cnt">cnt</option>
|
||||
<option value="clm">clm</option>
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 mt-md-0">
|
||||
<label class="form-label">Protocol</label>
|
||||
<select class="form-select" name="routingType">
|
||||
<option value="HTTP">HTTP (REST)</option>
|
||||
<option value="MCI">MCI (Legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
@@ -977,24 +1001,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Target Module</label>
|
||||
<select class="form-select" name="moduleName" id="targetModuleSelect" required>
|
||||
<option value="dap-was-oth">dap-was-oth</option>
|
||||
<option value="dap-was-hr">dap-was-hr</option>
|
||||
<option value="dap-was-sms">dap-was-sms</option>
|
||||
</select>
|
||||
<div class="input-hint">Destination Pod directory.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3 mt-md-0">
|
||||
<label class="form-label">Gateway Registration</label>
|
||||
<select class="form-select" name="register">
|
||||
<option value="false" selected>False (Private)</option>
|
||||
</select>
|
||||
<div class="input-hint">Controls dynamic routing exposure.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
@@ -1036,7 +1042,7 @@
|
||||
<div class="docgen-field">
|
||||
<label class="docgen-field-label" for="docgenToolSelect">Tool</label>
|
||||
<select id="docgenToolSelect" disabled><option>Tool Metadata를 불러오는 중...</option></select>
|
||||
<div id="docgenToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
|
||||
<div id="docgenGrowToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="docgen-two-col docgen-field">
|
||||
@@ -1175,7 +1181,8 @@
|
||||
<th style="min-width: 170px;">Enum values / List item type</th>
|
||||
<th style="min-width: 260px;">Object list item fields (JSON)</th>
|
||||
<th style="min-width: 210px;">Description</th>
|
||||
<th style="min-width: 180px;">Example</th>
|
||||
<th style="min-width: 150px;">Pattern (Regex)</th>
|
||||
<th style="min-width: 180px;">Examples (Enter 단위 구분)</th>
|
||||
<th style="min-width: 125px;">Required</th>
|
||||
<th style="width: 1%;">삭제</th>
|
||||
</tr>
|
||||
@@ -1197,6 +1204,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub Field Editor Modal -->
|
||||
<div class="modal fade" id="subFieldEditorModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">하위 필드(itemFields) 편집</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:15%">Name</th>
|
||||
<th style="width:15%">Type</th>
|
||||
<th style="width:15%">Details</th>
|
||||
<th style="width:15%">Description</th>
|
||||
<th style="width:10%">Pattern</th>
|
||||
<th style="width:15%">Examples</th>
|
||||
<th style="width:10%">Required</th>
|
||||
<th style="width:5%"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subFieldEditorBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn-action mt-2" onclick="addSubFieldEditorRow()">+ 하위 필드 추가</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary-action" data-bs-dismiss="modal">취소</button>
|
||||
<button type="button" class="btn-action" onclick="applySubFieldEditor()">적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Tool Modal -->
|
||||
<div class="modal fade" id="editToolModal" tabindex="-1" aria-labelledby="editToolModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@@ -1276,7 +1319,7 @@
|
||||
const button = dg('docgenGenerateBtn');
|
||||
select.disabled = true;
|
||||
button.disabled = true;
|
||||
dg('docgenToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
|
||||
dg('docgenGrowToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
|
||||
try {
|
||||
const response = await fetch('/mcp/api/v1/tools/list', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
|
||||
@@ -1291,7 +1334,7 @@
|
||||
`<option value="${index}">[${escapeHtml(tool.categoryKey || 'etc')}] ${escapeHtml(tool.name || tool.displayName || tool.uid)}</option>`
|
||||
).join('');
|
||||
select.disabled = state.tools.length === 0;
|
||||
dg('docgenToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
|
||||
dg('docgenGrowToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
|
||||
if (state.tools.length) {
|
||||
select.value = '0';
|
||||
selectTool();
|
||||
@@ -1303,7 +1346,7 @@
|
||||
state.selected = null;
|
||||
showMetadataError(error.message);
|
||||
select.innerHTML = '<option>Tool Metadata 조회 실패</option>';
|
||||
dg('docgenToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
|
||||
dg('docgenGrowToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,7 +1521,10 @@
|
||||
.then(modules => {
|
||||
const select = document.getElementById('targetModuleSelect');
|
||||
if (modules && modules.length > 0) {
|
||||
select.innerHTML = modules.map(m => `<option value="${m}" ${m === 'dap-was-oth' ? 'selected' : ''}>${m}</option>`).join('');
|
||||
select.innerHTML = modules.map(m => `<option value="${m}" ${m === 'dap-was-cus' ? 'selected' : ''}>${m}</option>`).join('');
|
||||
}
|
||||
if (typeof loadCategoriesForSelection === 'function') {
|
||||
loadCategoriesForSelection();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to load modules:', err));
|
||||
@@ -1566,8 +1612,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector('#toolForm [name="categoryKey"]').addEventListener('change', loadUseCasesForSelection);
|
||||
document.getElementById('targetModuleSelect').addEventListener('change', loadUseCasesForSelection);
|
||||
async function loadCategoriesForSelection() {
|
||||
const moduleName = document.getElementById('targetModuleSelect').value;
|
||||
const select = document.getElementById('domainCategorySelect');
|
||||
const nameInput = document.getElementById('domainCategoryName');
|
||||
select.innerHTML = '<option value="">새 도메인 생성</option>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/scaffold/categories?moduleName=${encodeURIComponent(moduleName)}`);
|
||||
if (!response.ok) throw new Error('Category 목록 조회 실패');
|
||||
const categories = await response.json();
|
||||
categories.forEach(cat => select.add(new Option(`기존 ${cat} 도메인`, cat)));
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
nameInput.value = '';
|
||||
nameInput.readOnly = false;
|
||||
loadUseCasesForSelection();
|
||||
}
|
||||
|
||||
document.getElementById('domainCategoryName').addEventListener('input', loadUseCasesForSelection);
|
||||
document.getElementById('targetModuleSelect').addEventListener('change', loadCategoriesForSelection);
|
||||
document.getElementById('domainCategorySelect').addEventListener('change', function() {
|
||||
const nameInput = document.getElementById('domainCategoryName');
|
||||
if (this.value) {
|
||||
nameInput.value = this.value;
|
||||
nameInput.readOnly = true;
|
||||
} else {
|
||||
nameInput.value = '';
|
||||
nameInput.readOnly = false;
|
||||
}
|
||||
loadUseCasesForSelection();
|
||||
});
|
||||
document.getElementById('toolGroupUseCaseSelect').addEventListener('change', function() {
|
||||
const nameInput = document.getElementById('toolGroupUseCaseName');
|
||||
if (this.value) {
|
||||
@@ -1587,26 +1662,43 @@
|
||||
}
|
||||
|
||||
document.getElementById('toolForm').addEventListener('submit', function(e) {
|
||||
if (!groupedTools.length) return;
|
||||
const useCaseName = document.getElementById('toolGroupUseCaseName').value.trim();
|
||||
if (useCaseName) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(useCaseName)) {
|
||||
alert('UseCase 이름은 PascalCase로 입력해주세요. 예: Customer');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const useCaseName = document.getElementById('toolGroupUseCaseName').value.trim();
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(useCaseName)) throw new Error('UseCase 이름은 PascalCase로 입력해주세요. 예: Customer');
|
||||
const current = currentToolDefinition();
|
||||
const tools = groupedTools.some(item => item.methodName === current.methodName) ? groupedTools : [...groupedTools, current];
|
||||
const data = Object.fromEntries(new FormData(this).entries());
|
||||
|
||||
const btn = this.querySelector('button[type="submit"]');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 처리중...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/api/v1/scaffold/tool-group', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({useCaseName, moduleName: data.moduleName, author: data.author, date: data.date, tools})
|
||||
}).then(response => response.text()).then(result => {
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
resultBox.style.display = 'block'; resultBox.className = result.startsWith('Error:') ? 'error' : 'success';
|
||||
resultBox.textContent = result;
|
||||
resultBox.textContent = result + (result.startsWith('Error:') ? '' : "\n\n> Note: Please sync Gradle in your IDE to reflect source code generation.");
|
||||
}).catch(error => {
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
resultBox.style.display = 'block'; resultBox.className = 'error';
|
||||
resultBox.textContent = "Network error: " + error;
|
||||
}).finally(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handleFormSubmit('toolForm', '/api/v1/scaffold/tool');
|
||||
@@ -1638,7 +1730,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'Enum', 'List'];
|
||||
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'List'];
|
||||
const typeExamples = {
|
||||
String: 'example',
|
||||
Integer: '1',
|
||||
@@ -1646,7 +1738,6 @@
|
||||
Double: '1.0',
|
||||
Boolean: 'true',
|
||||
BigDecimal: '1000.00',
|
||||
Enum: 'OPEN',
|
||||
List: 'C001'
|
||||
};
|
||||
const fieldTemplates = {
|
||||
@@ -1688,6 +1779,85 @@
|
||||
]
|
||||
}
|
||||
};
|
||||
let currentSubFieldTargetInput = null;
|
||||
let subFieldEditorModalInstance = null;
|
||||
|
||||
function openSubFieldEditor(inputEl) {
|
||||
currentSubFieldTargetInput = inputEl;
|
||||
let fields = [];
|
||||
if (inputEl.value.trim()) {
|
||||
try { fields = JSON.parse(inputEl.value); } catch(e) {}
|
||||
}
|
||||
document.getElementById('subFieldEditorBody').replaceChildren();
|
||||
(fields.length ? fields : [{}]).forEach(addSubFieldEditorRow);
|
||||
if (!subFieldEditorModalInstance) {
|
||||
subFieldEditorModalInstance = new bootstrap.Modal(document.getElementById('subFieldEditorModal'));
|
||||
}
|
||||
subFieldEditorModalInstance.show();
|
||||
}
|
||||
|
||||
function addSubFieldEditorRow(field = {}) {
|
||||
const row = document.createElement('tr');
|
||||
const makeInput = (val, ph) => {
|
||||
const el = document.createElement('input');
|
||||
el.type = 'text'; el.className = 'form-control form-control-sm';
|
||||
el.value = val || ''; el.placeholder = ph;
|
||||
return el;
|
||||
};
|
||||
const n = makeInput(field.name, 'name'); n.dataset.field = 'name';
|
||||
const d = makeInput(field.description, 'desc'); d.dataset.field = 'description';
|
||||
const p = makeInput(field.pattern, 'pattern'); p.dataset.field = 'pattern';
|
||||
const e = document.createElement('textarea');
|
||||
e.className = 'form-control form-control-sm';
|
||||
e.rows = 2; e.dataset.field = 'example';
|
||||
e.value = (field.examples || []).join('\n') || '';
|
||||
const t = document.createElement('select');
|
||||
t.className = 'form-select form-select-sm'; t.dataset.field = 'type';
|
||||
['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'].forEach(type => {
|
||||
t.add(new Option(type, type, false, (field.type || 'String') === type));
|
||||
});
|
||||
const det = makeInput(field.type !== 'List' ? (field.enumValues||[]).join(',') : '', 'Enum csv (콤마 구분)');
|
||||
det.dataset.field = 'details';
|
||||
t.addEventListener('change', () => {
|
||||
det.placeholder = 'Enum csv (콤마 구분)';
|
||||
});
|
||||
const req = document.createElement('select');
|
||||
req.className = 'form-select form-select-sm'; req.dataset.field = 'required';
|
||||
req.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
|
||||
req.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
|
||||
|
||||
[n, t, det, d, p, e, req].forEach(ctrl => {
|
||||
const td = document.createElement('td'); td.appendChild(ctrl); row.appendChild(td);
|
||||
});
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button'; del.className = 'btn-secondary-action'; del.textContent = 'X';
|
||||
del.style.padding = '0.1rem 0.4rem';
|
||||
del.onclick = () => row.remove();
|
||||
const delTd = document.createElement('td'); delTd.appendChild(del); row.appendChild(delTd);
|
||||
document.getElementById('subFieldEditorBody').appendChild(row);
|
||||
}
|
||||
|
||||
function applySubFieldEditor() {
|
||||
const fields = [...document.querySelectorAll('#subFieldEditorBody tr')].map(row => {
|
||||
const type = row.querySelector('[data-field="type"]').value;
|
||||
const details = row.querySelector('[data-field="details"]').value.trim();
|
||||
return {
|
||||
name: row.querySelector('[data-field="name"]').value.trim(),
|
||||
type,
|
||||
description: row.querySelector('[data-field="description"]').value.trim(),
|
||||
pattern: row.querySelector('[data-field="pattern"]').value.trim(),
|
||||
examples: row.querySelector('[data-field="example"]').value.split('\n').map(s=>s.trim()).filter(Boolean),
|
||||
required: row.querySelector('[data-field="required"]').value === 'true',
|
||||
enumValues: type !== 'List' ? details.split(',').map(s=>s.trim()).filter(Boolean) : [],
|
||||
itemType: null, itemFields: []
|
||||
};
|
||||
}).filter(f => f.name);
|
||||
currentSubFieldTargetInput.value = JSON.stringify(fields);
|
||||
currentSubFieldTargetInput.dispatchEvent(new Event('input'));
|
||||
currentSubFieldTargetInput.dispatchEvent(new Event('change'));
|
||||
subFieldEditorModalInstance.hide();
|
||||
}
|
||||
|
||||
let fieldEditorTargetId = null;
|
||||
let fieldEditorModalInstance = null;
|
||||
|
||||
@@ -1733,8 +1903,16 @@
|
||||
nameInput.dataset.field = 'name';
|
||||
const descriptionInput = makeInput(field.description, 'e.g. Employee ID');
|
||||
descriptionInput.dataset.field = 'description';
|
||||
const exampleInput = makeInput(field.example, 'e.g. EMP10001');
|
||||
|
||||
const patternInput = makeInput(field.pattern, 'e.g. ^[0-9]{4}$');
|
||||
patternInput.dataset.field = 'pattern';
|
||||
|
||||
const exampleInput = document.createElement('textarea');
|
||||
exampleInput.className = 'form-control form-control-sm';
|
||||
exampleInput.placeholder = 'e.g. EMP10001\nEMP10002';
|
||||
exampleInput.rows = 2;
|
||||
exampleInput.dataset.field = 'example';
|
||||
exampleInput.value = (field.examples || []).join('\n') || '';
|
||||
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'form-select form-select-sm';
|
||||
@@ -1752,13 +1930,13 @@
|
||||
input.add(new Option(itemType, itemType, false, (value || 'String') === itemType));
|
||||
});
|
||||
} else {
|
||||
input = makeInput(type === 'Enum' ? value : '', 'Enum: OPEN, CLOSED');
|
||||
input = makeInput(value, 'Enum csv (콤마 구분)');
|
||||
}
|
||||
input.dataset.field = 'details';
|
||||
return input;
|
||||
};
|
||||
let detailsInput = createDetailsInput(field.type || 'String',
|
||||
field.type === 'Enum' ? (field.enumValues || []).join(', ') : field.itemType);
|
||||
field.type !== 'List' ? (field.enumValues || []).join(', ') : field.itemType);
|
||||
|
||||
const requiredSelect = document.createElement('select');
|
||||
requiredSelect.className = 'form-select form-select-sm';
|
||||
@@ -1766,14 +1944,32 @@
|
||||
requiredSelect.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
|
||||
requiredSelect.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
|
||||
|
||||
const itemFieldsContainer = document.createElement('div');
|
||||
itemFieldsContainer.className = 'd-flex gap-1';
|
||||
|
||||
const itemFieldsInput = makeInput(field.type === 'List' && field.itemType === 'Object'
|
||||
? JSON.stringify(field.itemFields || []) : '', 'Object fields JSON e.g. [{"name":"date","type":"String"}]');
|
||||
? JSON.stringify(field.itemFields || []) : '', 'JSON 직접입력 또는 우측 편집 클릭');
|
||||
itemFieldsInput.dataset.field = 'itemFields';
|
||||
itemFieldsInput.style.flex = '1';
|
||||
|
||||
const editItemBtn = document.createElement('button');
|
||||
editItemBtn.type = 'button';
|
||||
editItemBtn.className = 'btn-secondary-action';
|
||||
editItemBtn.textContent = '편집';
|
||||
editItemBtn.style.padding = '0.2rem 0.5rem';
|
||||
editItemBtn.onclick = () => {
|
||||
if (typeof openSubFieldEditor === 'function') openSubFieldEditor(itemFieldsInput);
|
||||
};
|
||||
|
||||
itemFieldsContainer.appendChild(itemFieldsInput);
|
||||
itemFieldsContainer.appendChild(editItemBtn);
|
||||
|
||||
const updateObjectListFieldsState = () => {
|
||||
const objectList = typeSelect.value === 'List' && detailsInput.value === 'Object';
|
||||
itemFieldsInput.disabled = !objectList;
|
||||
editItemBtn.disabled = !objectList;
|
||||
itemFieldsInput.placeholder = objectList
|
||||
? 'Object fields JSON e.g. [{"name":"date","type":"String"}]'
|
||||
? 'JSON 직접입력 또는 우측 편집 클릭'
|
||||
: 'Select List > Object to enter item fields';
|
||||
if (!objectList) itemFieldsInput.value = '';
|
||||
};
|
||||
@@ -1795,12 +1991,12 @@
|
||||
});
|
||||
bindDetailsInput();
|
||||
updateObjectListFieldsState();
|
||||
[nameInput, descriptionInput, exampleInput, requiredSelect, itemFieldsInput].forEach(control => {
|
||||
[nameInput, descriptionInput, patternInput, exampleInput, requiredSelect, itemFieldsInput].forEach(control => {
|
||||
control.addEventListener('input', updateFieldEditorPreview);
|
||||
control.addEventListener('change', updateFieldEditorPreview);
|
||||
});
|
||||
|
||||
[nameInput, typeSelect, detailsInput, itemFieldsInput, descriptionInput, exampleInput, requiredSelect].forEach(control => {
|
||||
[nameInput, typeSelect, detailsInput, itemFieldsContainer, descriptionInput, patternInput, exampleInput, requiredSelect].forEach(control => {
|
||||
const cell = document.createElement('td');
|
||||
cell.appendChild(control);
|
||||
row.appendChild(cell);
|
||||
@@ -1828,16 +2024,20 @@
|
||||
let itemFields = [];
|
||||
if (type === 'List' && details === 'Object') {
|
||||
const itemFieldsText = row.querySelector('[data-field="itemFields"]').value.trim();
|
||||
if (!itemFieldsText) throw new Error('List Object는 항목 필드를 입력해야 합니다.');
|
||||
itemFields = JSON.parse(itemFieldsText);
|
||||
if (!Array.isArray(itemFields) || itemFields.length === 0) throw new Error('List Object 항목 필드는 JSON 배열이어야 합니다.');
|
||||
try {
|
||||
itemFields = itemFieldsText ? JSON.parse(itemFieldsText) : [];
|
||||
if (!Array.isArray(itemFields)) itemFields = [];
|
||||
} catch (e) {
|
||||
itemFields = [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: row.querySelector('[data-field="name"]').value.trim(), type,
|
||||
description: row.querySelector('[data-field="description"]').value.trim(),
|
||||
example: row.querySelector('[data-field="example"]').value.trim(),
|
||||
pattern: row.querySelector('[data-field="pattern"]').value.trim(),
|
||||
examples: row.querySelector('[data-field="example"]').value.split('\n').map(e => e.trim()).filter(Boolean),
|
||||
required: row.querySelector('[data-field="required"]').value === 'true',
|
||||
enumValues: type === 'Enum' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
|
||||
enumValues: type !== 'List' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
|
||||
itemType: type === 'List' ? (details || 'String') : null,
|
||||
itemFields
|
||||
};
|
||||
@@ -1920,7 +2120,10 @@
|
||||
form.elements.baseName.value = result.baseName || '';
|
||||
form.elements.title.value = result.title || '';
|
||||
form.elements.description.value = result.description || '';
|
||||
if (!form.elements.categoryKey.value) {
|
||||
form.elements.categoryKey.value = result.categoryKey || '';
|
||||
if (typeof loadUseCasesForSelection === 'function') loadUseCasesForSelection();
|
||||
}
|
||||
form.elements.routingType.value = result.routingType || 'HTTP';
|
||||
form.elements.httpApiName.value = result.httpApiName || '';
|
||||
form.elements.functionDescription.value = result.functionDescription || '';
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -178,7 +179,7 @@
|
||||
}
|
||||
const grouped = {};
|
||||
tools.forEach(tool => {
|
||||
const group = tool.categoryKey || 'Others';
|
||||
const group = tool._meta?.category_key || 'Others';
|
||||
if (!grouped[group]) grouped[group] = [];
|
||||
grouped[group].push(tool);
|
||||
});
|
||||
@@ -208,7 +209,7 @@
|
||||
const card = document.createElement('div');
|
||||
card.className = 'tool-card';
|
||||
|
||||
const isDirect = tool.integrationType === 'DIRECT';
|
||||
const isDirect = tool._meta?.integrationType === 'DIRECT';
|
||||
const badgeBg = isDirect ? 'rgba(59,130,246,0.15)' : 'rgba(249,115,22,0.15)';
|
||||
const badgeColor = isDirect ? '#60a5fa' : '#fb923c';
|
||||
const badgeBorder = isDirect ? 'rgba(59,130,246,0.3)' : 'rgba(249,115,22,0.3)';
|
||||
@@ -254,14 +255,14 @@
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between;">
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex; align-items:center; margin-bottom:4px;">
|
||||
<h3 style="font-size:16px; font-weight:600; color:#f4f4f5;">${tool.displayName || tool.name}</h3>
|
||||
<h3 style="font-size:16px; font-weight:600; color:#f4f4f5;">${tool.title || tool.name}</h3>
|
||||
<span style="margin:0 8px; color:#3f3f46;">/</span>
|
||||
<span class="geist-mono" style="font-size:12px; color:#71717a;">${tool.name}</span>
|
||||
</div>
|
||||
<p style="font-size:14px; color:#a1a1aa;">${tool.description || 'No description provided.'}</p>
|
||||
</div>
|
||||
<div style="margin-left:16px; padding:4px 10px; border-radius:6px; font-size:11px; font-weight:600; background:${badgeBg}; color:${badgeColor}; border:1px solid ${badgeBorder};" class="geist-mono">
|
||||
${tool.integrationType || 'DIRECT'}
|
||||
${tool._meta?.integrationType || 'DIRECT'}
|
||||
</div>
|
||||
</div>
|
||||
${paramHtml}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<a href="/chat.html" style="color:#ffffff;" class="font-semibold">Chat</a>
|
||||
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<a href="/chat.html" class="text-zinc-400 hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" class="text-zinc-400 hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" class="text-zinc-400 hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -205,7 +206,7 @@
|
||||
const tools = data.result?.tools || [];
|
||||
const groupedTools = {};
|
||||
tools.forEach(tool => {
|
||||
const group = tool.categoryKey || 'Others';
|
||||
const group = tool._meta?.category_key || 'Others';
|
||||
if (!groupedTools[group]) groupedTools[group] = [];
|
||||
groupedTools[group].push(tool);
|
||||
});
|
||||
@@ -218,7 +219,7 @@
|
||||
option.textContent = tool.name;
|
||||
option.dataset.schema = JSON.stringify(tool.parametersSchema);
|
||||
option.dataset.prompts = JSON.stringify(tool.actionPrompts || {});
|
||||
option.dataset.integrationType = tool.integrationType || 'HTTP';
|
||||
option.dataset.integrationType = tool._meta?.integrationType || 'HTTP';
|
||||
optgroup.appendChild(option);
|
||||
if (tool.actionPrompts) Object.assign(actionPrompts, tool.actionPrompts);
|
||||
});
|
||||
|
||||
@@ -331,7 +331,7 @@
|
||||
|
||||
const groupedTools = {};
|
||||
tools.forEach(tool => {
|
||||
const group = tool.domainGroup || '기타 그룹';
|
||||
const group = tool._meta?.category_key || '기타';
|
||||
if (!groupedTools[group]) {
|
||||
groupedTools[group] = [];
|
||||
}
|
||||
@@ -348,10 +348,10 @@
|
||||
|
||||
groupedTools[group].forEach(tool => {
|
||||
const option = document.createElement('option');
|
||||
option.value = tool.toolName;
|
||||
const commType = tool.integrationType ? tool.integrationType : 'HTTP';
|
||||
option.textContent = tool.description ? `[${commType}] ${tool.description} (${tool.toolName})` : `[${commType}] ${tool.toolName}`;
|
||||
option.dataset.schema = JSON.stringify(tool.parametersSchema);
|
||||
option.value = tool.name;
|
||||
const commType = tool._meta?.integrationType || 'HTTP';
|
||||
option.textContent = tool.description ? `[${commType}] ${tool.description} (${tool.name})` : `[${commType}] ${tool.name}`;
|
||||
option.dataset.schema = JSON.stringify(tool.inputSchema || tool.parametersSchema);
|
||||
option.dataset.prompts = JSON.stringify(tool.actionPrompts || {});
|
||||
optgroup.appendChild(option);
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
toolFunctions[tool.toolName] = enums.map(e => {
|
||||
toolFunctions[tool.name] = enums.map(e => {
|
||||
return { value: e, label: descMap[e] || e };
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
|
||||
<a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a>
|
||||
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -111,6 +112,9 @@
|
||||
|
||||
<div class="flex gap-4 mb-6 mt-6">
|
||||
<input type="text" id="searchInput" placeholder="Search by tool name..." class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 outline-none" oninput="applyFilters()">
|
||||
<select id="moduleFilter" class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-48 p-2.5 outline-none" onchange="applyFilters()">
|
||||
<option value="">All Modules</option>
|
||||
</select>
|
||||
<select id="categoryFilter" class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-48 p-2.5 outline-none" onchange="applyFilters()">
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
@@ -150,6 +154,7 @@
|
||||
<table class="table" id="toolsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:10%">Module</th>
|
||||
<th style="width:10%">Category</th>
|
||||
<th style="width:25%">Tool Name</th>
|
||||
<th style="width:25%">Payload</th>
|
||||
@@ -284,26 +289,35 @@
|
||||
const savedPayloads = JSON.parse(localStorage.getItem('mcp_tester_payloads') || '{}');
|
||||
|
||||
const categories = new Set();
|
||||
const modules = new Set();
|
||||
allTools.forEach(t => {
|
||||
t._customPayload = savedPayloads[t.name] || null;
|
||||
t._lastStatus = 'NOT RUN';
|
||||
t._isFailed = false;
|
||||
t._latency = 0;
|
||||
t._responseData = null;
|
||||
categories.add(t.categoryKey || 'oth');
|
||||
categories.add(t._meta?.category_key || 'oth');
|
||||
const modName = t.name ? t.name.split('_')[0].replace('dap-was-', '') : '';
|
||||
if (modName) {
|
||||
modules.add(modName);
|
||||
}
|
||||
});
|
||||
|
||||
const catSelect = document.getElementById('categoryFilter');
|
||||
catSelect.innerHTML = '<option value="">All Categories</option>';
|
||||
Array.from(categories).sort().forEach(cat => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat;
|
||||
opt.textContent = cat.toUpperCase();
|
||||
catSelect.appendChild(opt);
|
||||
catSelect.innerHTML += `<option value="${cat}">${cat.toUpperCase()}</option>`;
|
||||
});
|
||||
|
||||
const modSelect = document.getElementById('moduleFilter');
|
||||
modSelect.innerHTML = '<option value="">All Modules</option>';
|
||||
Array.from(modules).sort().forEach(mod => {
|
||||
modSelect.innerHTML += `<option value="${mod}">${mod}</option>`;
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
} catch (error) {
|
||||
document.getElementById('toolsTbody').innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-8">Failed to load tools.</td></tr>';
|
||||
document.getElementById('toolsTbody').innerHTML = '<tr><td colspan="6" class="text-center text-red-400 py-8">Failed to load tools.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,11 +354,14 @@
|
||||
function applyFilters() {
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
const category = document.getElementById('categoryFilter').value;
|
||||
const moduleFlt = document.getElementById('moduleFilter').value;
|
||||
|
||||
filteredTools = allTools.filter(t => {
|
||||
const matchSearch = t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search);
|
||||
const matchCategory = !category || (t.categoryKey || 'oth') === category;
|
||||
return matchSearch && matchCategory;
|
||||
const matchCategory = !category || (t._meta?.category_key || 'oth') === category;
|
||||
const toolMod = t.name ? t.name.split('_')[0].replace('dap-was-', '') : '';
|
||||
const matchModule = !moduleFlt || toolMod === moduleFlt;
|
||||
return matchSearch && matchCategory && matchModule;
|
||||
});
|
||||
|
||||
document.getElementById('visibleCount').textContent = `(${filteredTools.length} tools)`;
|
||||
@@ -355,7 +372,7 @@
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('toolsTbody');
|
||||
if (filteredTools.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-zinc-500 py-8">No tools match the filter.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-zinc-500 py-8">No tools match the filter.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,7 +396,8 @@
|
||||
|
||||
html += `
|
||||
<tr id="row-${index}">
|
||||
<td><span class="cat-badge">${tool.categoryKey || 'oth'}</span></td>
|
||||
<td><span class="cat-badge" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">${tool.name ? tool.name.split('_')[0].replace('dap-was-', '') : 'unknown'}</span></td>
|
||||
<td><span class="cat-badge">${tool._meta?.category_key || 'oth'}</span></td>
|
||||
<td class="font-medium text-slate-200">${tool.name}</td>
|
||||
<td class="text-sm text-zinc-400 geist-mono flex items-center gap-3">
|
||||
<span title='${payloadStr.replace(/'/g, "'")}'>${shortPayload}</span>
|
||||
|
||||
199
dap-gateway/src/main/resources/static/tool-report.html
Normal file
199
dap-gateway/src/main/resources/static/tool-report.html
Normal file
@@ -0,0 +1,199 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AXHUB Tool Report</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Geist", "Pretendard", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #09090b;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: #09090b; color: #f4f4f5; overflow-y: scroll; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid #27272a; background: rgba(9,9,11,.86); backdrop-filter: blur(16px); }
|
||||
.nav-shell { max-width: 1152px; height: 56px; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav-left, nav { display: flex; align-items: center; }
|
||||
.brand { display: flex; align-items: center; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
|
||||
.brand-dot { width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,.8); }
|
||||
.divider { width: 1px; height: 16px; margin: 0 20px; background: #27272a; }
|
||||
nav { gap: 20px; font-size: 13px; font-weight: 500; color: #a1a1aa; }
|
||||
nav a { transition: color .15s ease; white-space: nowrap; }
|
||||
nav a:hover { color: #fff; }
|
||||
nav .active { color: #fff; font-weight: 600; }
|
||||
.version { padding: 4px 8px; border: 1px solid rgba(59,130,246,.2); border-radius: 5px; background: rgba(59,130,246,.1); color: #60a5fa; font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
main { max-width: 1152px; margin: 0 auto; padding: 40px 24px 72px; }
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
|
||||
h1 { margin: 0 0 10px; font-size: 30px; line-height: 1.2; letter-spacing: -.035em; }
|
||||
.subtitle { margin: 0; color: #a1a1aa; font-size: 14px; line-height: 1.65; }
|
||||
.count-badge { padding: 7px 11px; border: 1px solid #27272a; border-radius: 8px; background: #18181b; color: #a1a1aa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; white-space: nowrap; }
|
||||
.panel { overflow: hidden; border: 1px solid #27272a; border-radius: 12px; background: #111113; box-shadow: 0 18px 50px rgba(0,0,0,.22); }
|
||||
.toolbar { display: grid; grid-template-columns: minmax(260px,1fr) 190px auto auto; gap: 10px; padding: 16px; border-bottom: 1px solid #27272a; background: #111113; }
|
||||
input, select, button { min-height: 40px; border: 1px solid #3f3f46; border-radius: 7px; background: #18181b; color: #e4e4e7; padding: 8px 12px; font: inherit; font-size: 13px; outline: none; }
|
||||
input::placeholder { color: #71717a; }
|
||||
input:focus, select:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
|
||||
button { cursor: pointer; font-weight: 600; transition: border-color .15s, background .15s, color .15s; }
|
||||
button:hover { border-color: #52525b; background: #27272a; color: #fff; }
|
||||
button.primary { min-width: 174px; border-color: #2563eb; background: #2563eb; color: #fff; }
|
||||
button.primary:hover { background: #1d4ed8; }
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.table-wrap { max-height: 570px; overflow: auto; }
|
||||
table { width: 100%; min-width: 980px; border-collapse: collapse; }
|
||||
th, td { padding: 13px 15px; border-bottom: 1px solid #27272a; text-align: left; vertical-align: top; }
|
||||
th { position: sticky; top: 0; z-index: 2; background: #18181b; color: #a1a1aa; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||
td { color: #d4d4d8; font-size: 13px; line-height: 1.55; }
|
||||
tbody tr { transition: background .12s ease; }
|
||||
tbody tr:hover { background: rgba(39,39,42,.5); }
|
||||
td:first-child, th:first-child { width: 58px; text-align: center; }
|
||||
input[type="checkbox"] { width: 16px; height: 16px; min-height: 0; accent-color: #3b82f6; }
|
||||
code { color: #60a5fa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; }
|
||||
.muted { color: #71717a; }
|
||||
.panel-footer { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px; border-top: 1px solid #27272a; background: #111113; }
|
||||
#message { color: #a1a1aa; font-size: 13px; }
|
||||
#message.error { color: #fca5a5; }
|
||||
.loading-row { padding: 42px !important; color: #71717a; text-align: center !important; }
|
||||
@media (max-width: 900px) {
|
||||
.nav-shell { overflow-x: auto; }
|
||||
.version { display: none; }
|
||||
nav { gap: 14px; }
|
||||
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="nav-shell">
|
||||
<div class="nav-left">
|
||||
<a id="gatewayHome" class="brand" href="#"><span class="brand-dot"></span><span>AXHUB Gateway</span></a>
|
||||
<div class="divider"></div>
|
||||
<nav>
|
||||
<a data-gateway-path="/admin/scaffold.html" href="#">Scaffold</a>
|
||||
<a data-gateway-path="/catalog.html" href="#">Catalog</a>
|
||||
<a data-gateway-path="/playground.html" href="#">Playground</a>
|
||||
<a data-gateway-path="/chat.html" href="#">Chat</a>
|
||||
<a data-gateway-path="/tester.html" href="#">Tester</a>
|
||||
<a data-gateway-path="/tool-test-console.html" href="#">Console</a>
|
||||
<a class="active" href="/tool-report.html">Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
<span class="version">v0.0.1</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>Tool Report</h1>
|
||||
<p class="subtitle">보고서에 포함할 MCP 툴을 선택하고, 소스 어노테이션과 전문 필드를 동일한 Excel 양식으로 생성합니다.</p>
|
||||
</div>
|
||||
<span id="summaryBadge" class="count-badge">Loading tools...</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
<input id="keyword" type="search" autocomplete="off" placeholder="툴명, 제목 또는 설명 검색">
|
||||
<select id="category"><option value="">전체 카테고리</option></select>
|
||||
<button id="selectAll" type="button">전체 선택</button>
|
||||
<button id="clearAll" type="button">전체 해제</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>선택</th><th>툴명</th><th>제목</th><th>카테고리</th><th>연계 ID</th><th>설명</th></tr></thead>
|
||||
<tbody id="tools"><tr><td class="loading-row" colspan="6">툴 목록을 불러오는 중입니다.</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<span id="message">소스 인덱스를 준비하고 있습니다.</span>
|
||||
<button id="download" type="button" class="primary" disabled>Excel 보고서 생성</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const state = { tools: [], selected: new Set() };
|
||||
const el = id => document.getElementById(id);
|
||||
|
||||
const gateway = location.origin;
|
||||
el('gatewayHome').href = gateway + '/index.html';
|
||||
document.querySelectorAll('[data-gateway-path]').forEach(link => link.href = gateway + link.dataset.gatewayPath);
|
||||
|
||||
function filtered() {
|
||||
const keyword = el('keyword').value.trim().toLowerCase();
|
||||
const category = el('category').value;
|
||||
return state.tools.filter(tool => (!category || tool.categoryKey === category) &&
|
||||
(!keyword || [tool.name, tool.title, tool.description].join(' ').toLowerCase().includes(keyword)));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const visible = filtered();
|
||||
el('tools').innerHTML = visible.length ? visible.map(tool => `<tr>
|
||||
<td><input type="checkbox" aria-label="${escapeHtml(tool.name)} 선택" data-name="${escapeHtml(tool.name)}" ${state.selected.has(tool.name) ? 'checked' : ''}></td>
|
||||
<td><code>${escapeHtml(tool.name)}</code></td><td>${escapeHtml(tool.title)}</td>
|
||||
<td>${escapeHtml(tool.categoryKey)}</td><td>${escapeHtml(tool.mappingId || '-')}</td><td class="muted">${escapeHtml(tool.description)}</td>
|
||||
</tr>`).join('') : '<tr><td class="loading-row" colspan="6">조건에 맞는 툴이 없습니다.</td></tr>';
|
||||
el('tools').querySelectorAll('input[type=checkbox]').forEach(box => box.addEventListener('change', event => {
|
||||
event.target.checked ? state.selected.add(event.target.dataset.name) : state.selected.delete(event.target.dataset.name);
|
||||
updateStatus();
|
||||
}));
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function updateStatus(text) {
|
||||
el('message').textContent = text || `전체 ${state.tools.length}개 중 ${state.selected.size}개 선택`;
|
||||
el('message').classList.remove('error');
|
||||
el('summaryBadge').textContent = `${state.tools.length} tools · ${state.selected.size} selected`;
|
||||
el('download').disabled = state.selected.size === 0;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch('/report/api/report-tools');
|
||||
if (!response.ok) throw new Error('툴 목록 조회에 실패했습니다.');
|
||||
state.tools = await response.json();
|
||||
[...new Set(state.tools.map(tool => tool.categoryKey))].sort().forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category; option.textContent = category; el('category').appendChild(option);
|
||||
});
|
||||
render();
|
||||
} catch (error) {
|
||||
el('tools').innerHTML = '<tr><td class="loading-row" colspan="6">툴 목록을 표시할 수 없습니다.</td></tr>';
|
||||
el('message').textContent = error.message; el('message').classList.add('error');
|
||||
el('summaryBadge').textContent = 'Load failed';
|
||||
}
|
||||
}
|
||||
|
||||
el('keyword').addEventListener('input', render);
|
||||
el('category').addEventListener('change', render);
|
||||
el('selectAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.add(tool.name)); render(); });
|
||||
el('clearAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.delete(tool.name)); render(); });
|
||||
el('download').addEventListener('click', async () => {
|
||||
el('download').disabled = true; updateStatus('소스를 분석하고 Excel을 생성하는 중입니다.');
|
||||
try {
|
||||
const response = await fetch('/report/api/tool-reports/excel', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({toolNames: [...state.selected]})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || '보고서 생성에 실패했습니다.');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = 'tool-report.xlsx'; anchor.click(); URL.revokeObjectURL(url);
|
||||
updateStatus(`선택한 ${state.selected.size}개 툴의 보고서를 생성했습니다.`);
|
||||
} catch (error) {
|
||||
el('message').textContent = error.message; el('message').classList.add('error');
|
||||
} finally { el('download').disabled = state.selected.size === 0; }
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,42 +0,0 @@
|
||||
package io.shinhanlife.dap.mcg;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg
|
||||
* @className DapGatewayApplicationTests
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import io.shinhanlife.dap.mcg.audit.AuditLogService;
|
||||
import io.shinhanlife.dap.mcg.redis.RedisToolTraceService;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class DapGatewayApplicationTests {
|
||||
|
||||
// Mock components that might require external dependencies (like Redis/DB) to pass the context load
|
||||
@MockitoBean
|
||||
private RedisToolTraceService redisToolTraceService;
|
||||
|
||||
@MockitoBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
// This test ensures that all Spring beans, @ConfigurationProperties, and dependencies are correctly wired.
|
||||
// It validates that the ported classes (ExecuteService, AgentResponseBudgetService, LargeToolResponseService, etc.)
|
||||
// have no @Autowired or initialization errors.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.dap.mcg;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DapGatewayApplicationTests {
|
||||
|
||||
@Test
|
||||
void gatewayTestSourceIsCompilable() {
|
||||
// Spring context integration tests are covered by feature-specific tests.
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ class DocumentGeneratorServiceTest {
|
||||
.name("oth.cmm.customer.detail")
|
||||
.description("테스트 고객의 상세정보를 조회합니다.")
|
||||
.categoryKey("cmm")
|
||||
.endpoint("http://was-oth:8084/mcp/oth.cmm.customer.detail")
|
||||
.podUrl("http://was-oth:8084")
|
||||
.endpoint("http://was-cus:8084/mcp/oth.cmm.customer.detail")
|
||||
.podUrl("http://was-cus:8084")
|
||||
.integrationType("REST")
|
||||
.mciServiceId("CUST_001")
|
||||
.operationType(OperationType.READ)
|
||||
@@ -108,7 +108,7 @@ class DocumentGeneratorServiceTest {
|
||||
assertThat(workbook.getSheet("Request In").getRow(1).getCell(2).getStringCellValue())
|
||||
.isEqualTo("테스트 고객조회 Tool");
|
||||
assertThat(workbook.getSheet("Request In").getRow(3).getCell(3).getStringCellValue())
|
||||
.isEqualTo("http://was-oth:8084/mcp/oth.cmm.customer.detail");
|
||||
.isEqualTo("http://was-cus:8084/mcp/oth.cmm.customer.detail");
|
||||
assertThat(workbook.getSheet("Request In").getRow(8).getCell(5).getStringCellValue())
|
||||
.isEqualTo("customerId");
|
||||
assertThat(workbook.getSheet("Request In").getRow(8).getCell(7).getStringCellValue())
|
||||
|
||||
14
dap-tool-report/Dockerfile
Normal file
14
dap-tool-report/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN chmod +x gradlew \
|
||||
&& ./gradlew :dap-tool-report:bootJar -x test --no-daemon \
|
||||
&& find dap-tool-report/build/libs -name '*-SNAPSHOT.jar' ! -name '*-plain.jar' -exec cp {} /workspace/app.jar \;
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Asia/Seoul
|
||||
COPY --from=builder /workspace/app.jar app.jar
|
||||
EXPOSE 8092
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
16
dap-tool-report/build.gradle
Normal file
16
dap-tool-report/build.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
id 'org.springframework.boot'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||
implementation 'com.github.javaparser:javaparser-core:3.26.3'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.3.0'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.dap.report;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.report
|
||||
* @className ToolReportApplication
|
||||
* @description 툴 소스를 읽기 전용으로 분석하여 Excel 보고서를 생성하는 독립 애플리케이션
|
||||
* @author 0986406
|
||||
* @create 2026.08.07
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.08.07 0986406 최초생성
|
||||
* </pre>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class ToolReportApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ToolReportApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.shinhanlife.dap.report.application;
|
||||
|
||||
import io.shinhanlife.dap.report.excel.ToolReportExcelWriter;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import io.shinhanlife.dap.report.source.ToolDetailAnalyzer;
|
||||
import io.shinhanlife.dap.report.source.ToolSourceDiscovery;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Gateway가 선택해 전달한 툴의 원천 분석과 Excel 출력을 담당한다. */
|
||||
@Service
|
||||
public class ToolReportService {
|
||||
|
||||
private final ToolSourceDiscovery discovery;
|
||||
private final ToolDetailAnalyzer analyzer;
|
||||
private final ToolReportExcelWriter excelWriter;
|
||||
|
||||
public ToolReportService(ToolSourceDiscovery discovery,
|
||||
ToolDetailAnalyzer analyzer,
|
||||
ToolReportExcelWriter excelWriter) {
|
||||
this.discovery = discovery;
|
||||
this.analyzer = analyzer;
|
||||
this.excelWriter = excelWriter;
|
||||
}
|
||||
|
||||
public byte[] createExcel(List<String> selectedNames) {
|
||||
if (selectedNames == null || selectedNames.isEmpty()) {
|
||||
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
|
||||
}
|
||||
|
||||
Map<String, ToolSummary> sourceTools = new LinkedHashMap<>();
|
||||
discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool));
|
||||
List<String> missingSources = selectedNames.stream()
|
||||
.filter(name -> !sourceTools.containsKey(name)).distinct().toList();
|
||||
if (!missingSources.isEmpty()) {
|
||||
throw new IllegalArgumentException("분석할 원천 소스가 없는 툴입니다: " + missingSources);
|
||||
}
|
||||
|
||||
List<ToolReportModel> reports = selectedNames.stream().distinct()
|
||||
.map(sourceTools::get)
|
||||
.map(analyzer::analyze)
|
||||
.toList();
|
||||
return excelWriter.write(reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.shinhanlife.dap.report.config;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** 보고서 모듈 설정. */
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(ReportProperties.class)
|
||||
public class ReportConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package io.shinhanlife.dap.report.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** 보고서 소스 위치와 출력 정책 설정. */
|
||||
@ConfigurationProperties(prefix = "report")
|
||||
public record ReportProperties(String sourceRoot, String outputFilenamePrefix) {
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package io.shinhanlife.dap.report.excel;
|
||||
|
||||
import io.shinhanlife.dap.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.Font;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
|
||||
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
|
||||
import org.apache.poi.xssf.usermodel.XSSFColor;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 모든 보고서 시트에 동일한 전문 I/O 문서 디자인을 적용한다. */
|
||||
@Component
|
||||
public class ToolReportExcelWriter {
|
||||
|
||||
private static final int HEADER_ROW = 7;
|
||||
private static final int FIRST_DATA_ROW = 8;
|
||||
|
||||
public byte[] write(List<ToolReportModel> reports) {
|
||||
try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
Styles styles = new Styles(workbook);
|
||||
writeSummary(workbook, reports, styles);
|
||||
writeFields(workbook, reports, styles);
|
||||
writeDiagnostics(workbook, reports, styles);
|
||||
workbook.write(output);
|
||||
return output.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Excel 보고서 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeSummary(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
|
||||
String[] headers = {"No.", "툴명", "제목", "설명", "카테고리", "연계 ID", "등록", "승인 필요",
|
||||
"Read Only", "Destructive", "Idempotent", "Open World", "Request", "Response", "UseCase", "원천 파일"};
|
||||
Sheet sheet = workbook.createSheet("툴 기본정보");
|
||||
decorateSheet(sheet, "MCP Tool Catalog Report", "선택 툴 기본정보", reports, headers.length, styles);
|
||||
header(sheet, headers, styles);
|
||||
int rowIndex = FIRST_DATA_ROW;
|
||||
int sequence = 1;
|
||||
for (ToolReportModel report : reports) {
|
||||
ToolSummary tool = report.tool();
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
Object[] values = {sequence++, tool.name(), tool.title(), tool.description(), tool.categoryKey(),
|
||||
tool.mappingId(), yn(tool.register()), yn(tool.requiresApproval()), yn(tool.readOnlyHint()),
|
||||
yn(tool.destructiveHint()), yn(tool.idempotentHint()), yn(tool.openWorldHint()),
|
||||
tool.requestType(), tool.responseType(), tool.useCaseClass(), tool.sourceFile()};
|
||||
values(row, values, styles.body);
|
||||
row.getCell(0).setCellStyle(styles.bodyCenter);
|
||||
}
|
||||
finishTable(sheet, rowIndex, headers.length,
|
||||
new int[]{7, 30, 24, 45, 12, 18, 10, 12, 12, 12, 12, 12, 20, 20, 25, 55});
|
||||
}
|
||||
|
||||
private void writeFields(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
|
||||
String[] headers = {"No.", "툴명", "원천 종류", "방향", "소유 타입", "필드명", "데이터 타입",
|
||||
"필수", "설명", "제약조건 / 어노테이션", "원천 파일"};
|
||||
Sheet sheet = workbook.createSheet("수집 필드");
|
||||
decorateSheet(sheet, "Tool Source I/O Report", "어노테이션 · DTO · 전문 필드", reports, headers.length, styles);
|
||||
header(sheet, headers, styles);
|
||||
int rowIndex = FIRST_DATA_ROW;
|
||||
int sequence = 1;
|
||||
for (ToolReportModel report : reports) {
|
||||
for (FieldDefinition field : report.fields()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
Object[] values = {sequence++, field.toolName(), field.sourceKind(), field.direction(),
|
||||
field.ownerType(), field.fieldName(), field.dataType(),
|
||||
field.required() == null ? "" : yn(field.required()), field.description(),
|
||||
field.constraints(), field.sourceFile()};
|
||||
values(row, values, styles.body);
|
||||
row.getCell(0).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(2).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(3).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(7).setCellStyle(styles.bodyCenter);
|
||||
}
|
||||
}
|
||||
finishTable(sheet, rowIndex, headers.length,
|
||||
new int[]{7, 30, 16, 11, 24, 24, 18, 9, 45, 55, 60});
|
||||
}
|
||||
|
||||
private void writeDiagnostics(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
|
||||
String[] headers = {"No.", "툴명", "수준", "진단 내용"};
|
||||
Sheet sheet = workbook.createSheet("분석 결과");
|
||||
decorateSheet(sheet, "Tool Analysis Report", "소스 분석 및 정합성 진단", reports, headers.length, styles);
|
||||
header(sheet, headers, styles);
|
||||
int rowIndex = FIRST_DATA_ROW;
|
||||
int sequence = 1;
|
||||
for (ToolReportModel report : reports) {
|
||||
if (report.diagnostics().isEmpty()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
values(row, new Object[]{sequence++, report.tool().name(), "정상", "분석 경고 없음"}, styles.body);
|
||||
row.getCell(0).setCellStyle(styles.bodyCenter);
|
||||
row.getCell(2).setCellStyle(styles.bodyCenter);
|
||||
} else {
|
||||
for (String diagnostic : report.diagnostics()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
values(row, new Object[]{sequence++, report.tool().name(), "경고", diagnostic}, styles.warning);
|
||||
row.getCell(0).setCellStyle(styles.warningCenter);
|
||||
row.getCell(2).setCellStyle(styles.warningCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
finishTable(sheet, rowIndex, headers.length, new int[]{7, 30, 12, 80});
|
||||
}
|
||||
|
||||
private void decorateSheet(Sheet sheet, String titleText, String reportType,
|
||||
List<ToolReportModel> reports, int columnCount, Styles styles) {
|
||||
sheet.setDisplayGridlines(false);
|
||||
sheet.setAutobreaks(true);
|
||||
sheet.getPrintSetup().setLandscape(true);
|
||||
sheet.getPrintSetup().setFitWidth((short) 1);
|
||||
sheet.getPrintSetup().setFitHeight((short) 0);
|
||||
sheet.setFitToPage(true);
|
||||
|
||||
Row title = sheet.createRow(0);
|
||||
title.setHeightInPoints(24);
|
||||
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, columnCount - 1));
|
||||
Cell titleCell = title.createCell(0);
|
||||
titleCell.setCellValue(titleText);
|
||||
titleCell.setCellStyle(styles.title);
|
||||
|
||||
metadataRow(sheet, 2, "보고서 구분", reportType, columnCount, styles);
|
||||
metadataRow(sheet, 3, "선택 툴 수", reports.size(), columnCount, styles);
|
||||
metadataRow(sheet, 4, "생성 일시",
|
||||
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), columnCount, styles);
|
||||
}
|
||||
|
||||
private void metadataRow(Sheet sheet, int rowIndex, String label, Object value,
|
||||
int columnCount, Styles styles) {
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.setHeightInPoints(19);
|
||||
int labelEnd = Math.min(1, columnCount - 1);
|
||||
if (labelEnd > 0) sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, labelEnd));
|
||||
Cell labelCell = row.createCell(0);
|
||||
labelCell.setCellValue(label);
|
||||
labelCell.setCellStyle(styles.metaLabel);
|
||||
int valueStart = labelEnd + 1;
|
||||
if (valueStart < columnCount - 1) {
|
||||
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, valueStart, columnCount - 1));
|
||||
}
|
||||
Cell valueCell = row.createCell(valueStart);
|
||||
if (value instanceof Number number) valueCell.setCellValue(number.doubleValue());
|
||||
else valueCell.setCellValue(value == null ? "" : String.valueOf(value));
|
||||
valueCell.setCellStyle(styles.metaValue);
|
||||
}
|
||||
|
||||
private void header(Sheet sheet, String[] headers, Styles styles) {
|
||||
Row row = sheet.createRow(HEADER_ROW);
|
||||
row.setHeightInPoints(30);
|
||||
for (int column = 0; column < headers.length; column++) {
|
||||
Cell cell = row.createCell(column);
|
||||
cell.setCellValue(headers[column]);
|
||||
cell.setCellStyle(styles.header);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishTable(Sheet sheet, int rowIndex, int columnCount, int[] characterWidths) {
|
||||
widths(sheet, characterWidths);
|
||||
sheet.createFreezePane(0, FIRST_DATA_ROW);
|
||||
sheet.setAutoFilter(new CellRangeAddress(HEADER_ROW, Math.max(HEADER_ROW, rowIndex - 1), 0, columnCount - 1));
|
||||
sheet.setRepeatingRows(new CellRangeAddress(HEADER_ROW, HEADER_ROW, -1, -1));
|
||||
}
|
||||
|
||||
private void values(Row row, Object[] values, CellStyle style) {
|
||||
row.setHeightInPoints(19);
|
||||
for (int column = 0; column < values.length; column++) {
|
||||
Cell cell = row.createCell(column);
|
||||
Object value = values[column];
|
||||
if (value instanceof Number number) cell.setCellValue(number.doubleValue());
|
||||
else cell.setCellValue(value == null ? "" : String.valueOf(value));
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
}
|
||||
|
||||
private void widths(Sheet sheet, int[] characterWidths) {
|
||||
for (int i = 0; i < characterWidths.length; i++) {
|
||||
sheet.setColumnWidth(i, Math.min(255, characterWidths[i]) * 256);
|
||||
}
|
||||
}
|
||||
|
||||
private String yn(boolean value) {
|
||||
return value ? "Y" : "N";
|
||||
}
|
||||
|
||||
private static final class Styles {
|
||||
private final CellStyle title;
|
||||
private final CellStyle metaLabel;
|
||||
private final CellStyle metaValue;
|
||||
private final CellStyle header;
|
||||
private final CellStyle body;
|
||||
private final CellStyle bodyCenter;
|
||||
private final CellStyle warning;
|
||||
private final CellStyle warningCenter;
|
||||
|
||||
private Styles(Workbook workbook) {
|
||||
title = style(workbook, "#262626", "#FFFFFF", 12, true, HorizontalAlignment.CENTER, false);
|
||||
metaLabel = style(workbook, "#333333", "#FFFFFF", 10, true, HorizontalAlignment.CENTER, false);
|
||||
metaValue = style(workbook, "#FFFFFF", "#222222", 10, false, HorizontalAlignment.LEFT, false);
|
||||
header = style(workbook, "#404040", "#FFFFFF", 9, true, HorizontalAlignment.CENTER, true);
|
||||
body = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.LEFT, true);
|
||||
bodyCenter = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.CENTER, true);
|
||||
warning = style(workbook, "#FFF2CC", "#7F6000", 9, false, HorizontalAlignment.LEFT, true);
|
||||
warningCenter = style(workbook, "#FFF2CC", "#7F6000", 9, true, HorizontalAlignment.CENTER, true);
|
||||
}
|
||||
|
||||
private CellStyle style(Workbook workbook, String fill, String fontColor, int size, boolean bold,
|
||||
HorizontalAlignment alignment, boolean borders) {
|
||||
XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle();
|
||||
style.setFillForegroundColor(color(fill));
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
style.setAlignment(alignment);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
style.setWrapText(true);
|
||||
Font font = workbook.createFont();
|
||||
font.setFontName("Carlito");
|
||||
font.setFontHeightInPoints((short) size);
|
||||
font.setBold(bold);
|
||||
((org.apache.poi.xssf.usermodel.XSSFFont) font).setColor(color(fontColor));
|
||||
style.setFont(font);
|
||||
if (borders) applyBorders(style);
|
||||
return style;
|
||||
}
|
||||
|
||||
private void applyBorders(CellStyle style) {
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
short borderColor = org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex();
|
||||
style.setTopBorderColor(borderColor);
|
||||
style.setBottomBorderColor(borderColor);
|
||||
style.setLeftBorderColor(borderColor);
|
||||
style.setRightBorderColor(borderColor);
|
||||
}
|
||||
|
||||
private XSSFColor color(String hex) {
|
||||
return new XSSFColor(Color.decode(hex), new DefaultIndexedColorMap());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
/** DTO, JSON Schema 또는 원천 전문에서 수집한 필드 정의. */
|
||||
public record FieldDefinition(
|
||||
String toolName,
|
||||
String sourceKind,
|
||||
String direction,
|
||||
String ownerType,
|
||||
String fieldName,
|
||||
String dataType,
|
||||
Boolean required,
|
||||
String description,
|
||||
String constraints,
|
||||
String sourceFile) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 수집 단계와 Excel 출력 단계를 분리하는 표준 중간 모델. */
|
||||
public record ToolReportModel(ToolSummary tool, List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 사용자가 선택한 툴 보고서 생성 요청. */
|
||||
public record ToolReportRequest(List<String> toolNames) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.shinhanlife.dap.report.model;
|
||||
|
||||
/** UI 선택 목록과 보고서 기본정보에 사용하는 툴 요약. */
|
||||
public record ToolSummary(
|
||||
String name,
|
||||
String title,
|
||||
String description,
|
||||
String categoryKey,
|
||||
String mappingId,
|
||||
boolean register,
|
||||
boolean requiresApproval,
|
||||
boolean readOnlyHint,
|
||||
boolean destructiveHint,
|
||||
boolean idempotentHint,
|
||||
boolean openWorldHint,
|
||||
String requestType,
|
||||
String responseType,
|
||||
String useCaseClass,
|
||||
String sourceFile,
|
||||
String inputSchemaResource,
|
||||
String outputSchemaResource) {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.shinhanlife.dap.report.presentation;
|
||||
|
||||
import io.shinhanlife.dap.report.application.ToolReportService;
|
||||
import io.shinhanlife.dap.report.config.ReportProperties;
|
||||
import io.shinhanlife.dap.report.model.ToolReportRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 툴 선택 목록과 Excel 다운로드 API. */
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ToolReportController {
|
||||
|
||||
private final ToolReportService service;
|
||||
private final ReportProperties properties;
|
||||
|
||||
public ToolReportController(ToolReportService service, ReportProperties properties) {
|
||||
this.service = service;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/tool-reports/excel",
|
||||
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> excel(@RequestBody ToolReportRequest request) {
|
||||
byte[] content = service.createExcel(request.toolNames());
|
||||
String prefix = properties.outputFilenamePrefix() == null || properties.outputFilenamePrefix().isBlank()
|
||||
? "tool-report" : properties.outputFilenamePrefix();
|
||||
String filename = prefix + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
|
||||
ContentDisposition disposition = ContentDisposition.attachment()
|
||||
.filename(filename, StandardCharsets.UTF_8)
|
||||
.build();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
|
||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.contentLength(content.length)
|
||||
.body(content);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> badRequest(IllegalArgumentException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import com.github.javaparser.ast.NodeList;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.expr.MemberValuePair;
|
||||
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.StringLiteralExpr;
|
||||
import java.util.Optional;
|
||||
|
||||
/** JavaParser AST에서 어노테이션 값을 안전하게 읽는 도우미. */
|
||||
final class JavaAnnotationReader {
|
||||
|
||||
private JavaAnnotationReader() {
|
||||
}
|
||||
|
||||
static Optional<AnnotationExpr> find(NodeList<AnnotationExpr> annotations, String simpleName) {
|
||||
return annotations.stream().filter(a -> a.getName().getIdentifier().equals(simpleName)).findFirst();
|
||||
}
|
||||
|
||||
static Optional<Expression> value(AnnotationExpr annotation, String key) {
|
||||
if (annotation instanceof NormalAnnotationExpr normal) {
|
||||
return normal.getPairs().stream()
|
||||
.filter(pair -> pair.getNameAsString().equals(key))
|
||||
.map(MemberValuePair::getValue)
|
||||
.findFirst();
|
||||
}
|
||||
if (annotation instanceof SingleMemberAnnotationExpr single && "value".equals(key)) {
|
||||
return Optional.of(single.getMemberValue());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static String string(AnnotationExpr annotation, String key, String fallback) {
|
||||
return value(annotation, key)
|
||||
.filter(StringLiteralExpr.class::isInstance)
|
||||
.map(StringLiteralExpr.class::cast)
|
||||
.map(StringLiteralExpr::asString)
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
static boolean bool(AnnotationExpr annotation, String key, boolean fallback) {
|
||||
return value(annotation, key)
|
||||
.filter(BooleanLiteralExpr.class::isInstance)
|
||||
.map(BooleanLiteralExpr.class::cast)
|
||||
.map(BooleanLiteralExpr::getValue)
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
static Optional<AnnotationExpr> nested(AnnotationExpr annotation, String key) {
|
||||
return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import io.shinhanlife.dap.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 선택된 툴의 DTO, JSON Schema와 원천 전문 필드를 수집한다. */
|
||||
@Component
|
||||
public class ToolDetailAnalyzer {
|
||||
|
||||
private final ToolSourceDiscovery discovery;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolDetailAnalyzer(ToolSourceDiscovery discovery) {
|
||||
this.discovery = discovery;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public ToolReportModel analyze(ToolSummary tool) {
|
||||
List<FieldDefinition> fields = new ArrayList<>();
|
||||
List<String> diagnostics = new ArrayList<>();
|
||||
Map<String, Path> javaFiles = indexJavaFiles();
|
||||
|
||||
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
|
||||
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
|
||||
if (tool.inputSchemaResource().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
|
||||
}
|
||||
if (tool.outputSchemaResource().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
|
||||
}
|
||||
if (!tool.mappingId().isBlank()) {
|
||||
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_I"), "TELEGRAM", "INPUT", fields, diagnostics);
|
||||
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_O"), "TELEGRAM", "OUTPUT", fields, diagnostics);
|
||||
}
|
||||
return new ToolReportModel(tool, List.copyOf(fields), List.copyOf(diagnostics));
|
||||
}
|
||||
|
||||
private Map<String, Path> indexJavaFiles() {
|
||||
Map<String, Path> result = new LinkedHashMap<>();
|
||||
try (var paths = Files.walk(discovery.sourceRoot())) {
|
||||
paths.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".java"))
|
||||
.forEach(path -> result.putIfAbsent(path.getFileName().toString().replace(".java", ""), path));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to index Java sources", exception);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collectSchema(ToolSummary tool, String resource, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (resource == null || resource.isBlank()) return;
|
||||
String relative = resource.replaceFirst("^classpath:", "").replaceFirst("^/", "");
|
||||
List<Path> matches = new ArrayList<>();
|
||||
try (var paths = Files.walk(discovery.sourceRoot())) {
|
||||
paths.filter(path -> Files.isRegularFile(path))
|
||||
.filter(path -> path.toString().replace('\\', '/').endsWith("/src/main/resources/" + relative))
|
||||
.forEach(matches::add);
|
||||
} catch (IOException exception) {
|
||||
diagnostics.add("Schema 검색 실패: " + resource);
|
||||
return;
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
diagnostics.add("Schema를 찾을 수 없음: " + resource);
|
||||
return;
|
||||
}
|
||||
Path path = matches.get(0);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(path.toFile());
|
||||
JsonNode properties = root.path("properties");
|
||||
List<String> required = new ArrayList<>();
|
||||
root.path("required").forEach(node -> required.add(node.asText()));
|
||||
Iterator<Map.Entry<String, JsonNode>> iterator = properties.fields();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = iterator.next();
|
||||
JsonNode definition = entry.getValue();
|
||||
fields.add(new FieldDefinition(tool.name(), "JSON_SCHEMA", direction, "",
|
||||
entry.getKey(), definition.path("type").asText("object"),
|
||||
required.contains(entry.getKey()), definition.path("description").asText(""),
|
||||
constraints(definition), relative(path)));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add("Schema 분석 실패: " + relative(path));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectJavaType(ToolSummary tool, Path path, String sourceKind, String direction,
|
||||
List<FieldDefinition> fields, List<String> diagnostics) {
|
||||
if (path == null) {
|
||||
diagnostics.add(sourceKind + " " + direction + " 타입을 찾을 수 없음");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompilationUnit unit = StaticJavaParser.parse(path);
|
||||
for (TypeDeclaration<?> type : unit.getTypes()) {
|
||||
collectFields(tool, path, type, sourceKind, direction, fields);
|
||||
type.getMembers().stream().filter(TypeDeclaration.class::isInstance)
|
||||
.map(TypeDeclaration.class::cast)
|
||||
.forEach(nested -> collectFields(tool, path, nested, sourceKind, direction, fields));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
diagnostics.add(sourceKind + " 분석 실패: " + relative(path));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFields(ToolSummary tool, Path path, TypeDeclaration<?> owner, String sourceKind,
|
||||
String direction, List<FieldDefinition> fields) {
|
||||
for (FieldDeclaration declaration : owner.getFields()) {
|
||||
AnnotationExpr param = JavaAnnotationReader.find(declaration.getAnnotations(), "McpToolParam").orElse(null);
|
||||
AnnotationExpr schema = JavaAnnotationReader.find(declaration.getAnnotations(), "Schema").orElse(null);
|
||||
AnnotationExpr telegram = JavaAnnotationReader.find(declaration.getAnnotations(), "GlowTrgmField").orElse(null);
|
||||
declaration.getVariables().forEach(variable -> fields.add(new FieldDefinition(
|
||||
tool.name(), sourceKind, direction, owner.getNameAsString(), variable.getNameAsString(),
|
||||
variable.getTypeAsString(),
|
||||
param == null ? null : JavaAnnotationReader.bool(param, "required", false),
|
||||
param == null ? "" : JavaAnnotationReader.string(param, "description", ""),
|
||||
annotationConstraints(schema, telegram), relative(path))));
|
||||
}
|
||||
}
|
||||
|
||||
private String annotationConstraints(AnnotationExpr schema, AnnotationExpr telegram) {
|
||||
List<String> values = new ArrayList<>();
|
||||
if (schema != null) values.add("Schema=" + schema);
|
||||
if (telegram != null) values.add("GlowTrgmField=" + telegram);
|
||||
return String.join("; ", values);
|
||||
}
|
||||
|
||||
private String constraints(JsonNode node) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (String key : List.of("format", "pattern", "minimum", "maximum", "minLength", "maxLength", "example")) {
|
||||
if (node.has(key)) values.add(key + "=" + node.get(key).asText());
|
||||
}
|
||||
if (node.has("enum")) values.add("enum=" + node.get("enum"));
|
||||
return String.join("; ", values);
|
||||
}
|
||||
|
||||
private String relative(Path path) {
|
||||
return discovery.sourceRoot().relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import io.shinhanlife.dap.report.config.ReportProperties;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 프로젝트의 @McpTool 선언을 읽기 전용으로 탐색한다. */
|
||||
@Component
|
||||
public class ToolSourceDiscovery {
|
||||
|
||||
private final Path sourceRoot;
|
||||
|
||||
public ToolSourceDiscovery(ReportProperties properties) {
|
||||
String configuredRoot = properties.sourceRoot();
|
||||
if (configuredRoot == null || configuredRoot.isBlank()) {
|
||||
throw new IllegalArgumentException("report.source-root must not be blank");
|
||||
}
|
||||
this.sourceRoot = resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
public Path sourceRoot() {
|
||||
return sourceRoot;
|
||||
}
|
||||
|
||||
public List<ToolSummary> discover() {
|
||||
if (!Files.isDirectory(sourceRoot)) {
|
||||
throw new IllegalStateException("Report source root does not exist: " + sourceRoot);
|
||||
}
|
||||
List<ToolSummary> tools = new ArrayList<>();
|
||||
try (var paths = Files.walk(sourceRoot)) {
|
||||
paths.filter(this::isUseCaseSource).forEach(path -> parse(path, tools));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
|
||||
}
|
||||
return tools.stream()
|
||||
.sorted(Comparator.comparing(ToolSummary::sourceFile))
|
||||
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
|
||||
(first, duplicate) -> first, LinkedHashMap::new))
|
||||
.values().stream()
|
||||
.sorted(Comparator.comparing(ToolSummary::name))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Path resolveProjectRoot(Path configuredPath) {
|
||||
Path candidate = configuredPath;
|
||||
while (candidate != null) {
|
||||
if (Files.isRegularFile(candidate.resolve("settings.gradle"))
|
||||
&& Files.isDirectory(candidate.resolve("dap-was-lib"))) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = candidate.getParent();
|
||||
}
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
private boolean isUseCaseSource(Path path) {
|
||||
String normalized = path.toString().replace('\\', '/');
|
||||
return Files.isRegularFile(path)
|
||||
&& path.getFileName().toString().endsWith("UseCase.java")
|
||||
&& normalized.contains("/src/main/java/")
|
||||
&& !normalized.contains("/dap-tool-report/");
|
||||
}
|
||||
|
||||
private void parse(Path path, List<ToolSummary> tools) {
|
||||
try {
|
||||
CompilationUnit unit = StaticJavaParser.parse(path);
|
||||
String owner = unit.getPrimaryTypeName().orElse(path.getFileName().toString().replace(".java", ""));
|
||||
for (MethodDeclaration method : unit.findAll(MethodDeclaration.class)) {
|
||||
JavaAnnotationReader.find(method.getAnnotations(), "McpTool")
|
||||
.ifPresent(annotation -> tools.add(toSummary(path, owner, method, annotation)));
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Failed to parse tool source: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
|
||||
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "ToolHint").orElse(null);
|
||||
AnnotationExpr annotations = JavaAnnotationReader.nested(tool, "annotations").orElse(null);
|
||||
String name = JavaAnnotationReader.string(tool, "name", method.getNameAsString());
|
||||
String title = JavaAnnotationReader.string(tool, "title", name);
|
||||
String description = JavaAnnotationReader.string(tool, "description", "");
|
||||
String requestType = method.getParameters().isEmpty() ? "" : method.getParameter(0).getTypeAsString();
|
||||
return new ToolSummary(
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
hint == null ? "common" : JavaAnnotationReader.string(hint, "categoryKey", "com"),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "mappingId", ""),
|
||||
hint != null && JavaAnnotationReader.bool(hint, "register", false),
|
||||
hint != null && JavaAnnotationReader.bool(hint, "requiresApproval", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "readOnlyHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "destructiveHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "idempotentHint", false),
|
||||
annotations != null && JavaAnnotationReader.bool(annotations, "openWorldHint", false),
|
||||
requestType,
|
||||
method.getTypeAsString(),
|
||||
owner,
|
||||
sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "inputSchemaResource", ""),
|
||||
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""));
|
||||
}
|
||||
}
|
||||
11
dap-tool-report/src/main/resources/application.yml
Normal file
11
dap-tool-report/src/main/resources/application.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
server:
|
||||
address: ${REPORT_SERVER_ADDRESS:127.0.0.1}
|
||||
port: ${REPORT_SERVER_PORT:8092}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: dap-tool-report
|
||||
|
||||
report:
|
||||
source-root: ${REPORT_SOURCE_ROOT:.}
|
||||
output-filename-prefix: tool-report
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.shinhanlife.dap.report.excel;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.shinhanlife.dap.report.model.FieldDefinition;
|
||||
import io.shinhanlife.dap.report.model.ToolReportModel;
|
||||
import io.shinhanlife.dap.report.model.ToolSummary;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.List;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolReportExcelWriterTest {
|
||||
|
||||
@Test
|
||||
void writesFixedWorkbookStructure() throws Exception {
|
||||
ToolSummary tool = new ToolSummary("oth.cst.customer.detail", "고객 상세", "설명", "cst", "ONCSC1340",
|
||||
false, false, true, false, false, true, "Request", "Response", "UseCase", "UseCase.java", "", "");
|
||||
FieldDefinition field = new FieldDefinition(tool.name(), "TELEGRAM", "INPUT", "ONCSC1340_I",
|
||||
"customerNo", "String", true, "고객번호", "length=12", "ONCSC1340_I.java");
|
||||
byte[] content = new ToolReportExcelWriter().write(List.of(new ToolReportModel(tool, List.of(field), List.of())));
|
||||
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
|
||||
assertThat(workbook.getSheet("툴 기본정보").getRow(8).getCell(1).getStringCellValue()).isEqualTo(tool.name());
|
||||
assertThat(workbook.getSheet("수집 필드").getRow(8).getCell(5).getStringCellValue()).isEqualTo("customerNo");
|
||||
assertThat(workbook.getSheet("툴 기본정보").getRow(0).getCell(0).getCellStyle().getFillForegroundColorColor().getARGBHex())
|
||||
.endsWith("262626");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.shinhanlife.dap.report.source;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.shinhanlife.dap.report.config.ReportProperties;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolSourceDiscoveryTest {
|
||||
|
||||
@Test
|
||||
void discoversMcpToolsFromProjectSources() {
|
||||
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
|
||||
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
|
||||
new ReportProperties(projectRoot.toString(), "tool-report"));
|
||||
|
||||
assertThat(discovery.discover())
|
||||
.extracting(tool -> tool.name())
|
||||
.contains("oth.cmm.customer.detail", "sms.sms.msg.send");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Asia/Seoul
|
||||
COPY dap-was-oth/build/libs/*-SNAPSHOT.jar app.jar
|
||||
COPY dap-was-cus/build/libs/*-SNAPSHOT.jar app.jar
|
||||
EXPOSE 8084
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AnalysisDataQueryRequest {
|
||||
@Schema(description = "데이터 조회를 시작할 날짜", example = "2024-01-01")
|
||||
private String startDate;
|
||||
|
||||
@Schema(description = "데이터 조회를 종료할 날짜", example = "2024-12-31")
|
||||
private String endDate;
|
||||
|
||||
@Schema(description = "분석할 데이터 카테고리", example = "sales")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "지역 필터", example = "seoul")
|
||||
private String region;
|
||||
|
||||
@Schema(description = "조회할 결과 수 제한", example = "100")
|
||||
private Integer limit;
|
||||
|
||||
@Schema(description = "조회할 결과 시작 위치", example = "0")
|
||||
private Integer offset;
|
||||
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.usecase;
|
||||
|
||||
import io.shinhanlife.glow.BaseResponse;
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.CustomerGuidanceToolRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.CustomerGuidanceToolResponse;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -25,6 +25,6 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
public interface CustomerGuidanceToolUseCase {
|
||||
|
||||
@McpTool(name = "cmm_customer_tool", title = "고객 통합 안내이력 조회", description = "고객의 통합 안내 이력을 조회하는 도구입니다. 고객 ID와 조회 기간을 입력하면 해당 기간 동안의 안내 이력을 반환합니다.")
|
||||
@ToolHint(register = false, categoryKey = "cmm", mappingId = "ONILD0320")
|
||||
@GrowToolHint(categoryKey = "cmm", mappingId = "ONILD0320")
|
||||
CustomerGuidanceToolResponse searchCustomerGuidance(CustomerGuidanceToolRequest req) throws Exception;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
|
||||
|
||||
@@ -21,6 +21,6 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
|
||||
*/
|
||||
public interface MetaCommonCodeUseCase {
|
||||
@McpTool(name = "cmm_comcode_lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true))
|
||||
@ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
|
||||
@GrowToolHint(requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
|
||||
Object searchCommonCode(MetaCommonCodeRequest req);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
|
||||
|
||||
@@ -21,6 +21,6 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
|
||||
*/
|
||||
public interface MetaTableUseCase {
|
||||
@McpTool(name = "cmm_meta_table", title = "메타 테이블 조회 툴", description = "메타 테이블 정보 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true))
|
||||
@ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
|
||||
@GrowToolHint(requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
|
||||
Object searchMetaTable(MetaTableRequest req);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package io.shinhanlife.dap.mcc.biz.cmm.usecase;
|
||||
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface TemplateUtilityUseCase {
|
||||
@McpTool(name = "cmm_template_url", title = "템플릿 유틸리티 툴", description = "요청한 템플릿(엑셀, 워드 등) 파일(양식)을 다운로드 받을 수 있는 시스템 URL을 반환합니다. AI는 이 URL을 사용자에게 마크다운 링크 형태로 제공해야 합니다.")
|
||||
@ToolHint(categoryKey = "cmm")
|
||||
@GrowToolHint(categoryKey = "cmm")
|
||||
Map<String, Object> getTemplateFileUrl(TemplateDownloadRequest req);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
|
||||
|
||||
@@ -22,6 +22,6 @@ import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
|
||||
public interface InsuranceClaimProcessorUseCase {
|
||||
|
||||
@McpTool(name = "ins_insurance_processor", title = "보험금 청구", description = "보험금 청구 요청을 처리하고 결과를 반환하는 LLM 도구 가이드")
|
||||
@ToolHint(register = false, categoryKey = "ins", mappingId = "CLAIM0000001")
|
||||
@GrowToolHint(categoryKey = "ins", mappingId = "CLAIM0000001")
|
||||
InsuranceClaimProcessorResponse processInsuranceClaim(InsuranceClaimProcessorRequest req);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package io.shinhanlife.dap.mcc.biz.oth.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.oth.dto.*;
|
||||
|
||||
public interface Onnba3011UseCase {
|
||||
@McpTool(name = "oth_onnba3011_call", description = "Onnba3011 호출 툴")
|
||||
@ToolHint(categoryKey = "oth", register = false)
|
||||
@GrowToolHint(categoryKey = "oth")
|
||||
Object callOnnba3011(Onnba3011Request req);
|
||||
}
|
||||
@@ -2,12 +2,12 @@ package io.shinhanlife.dap.mcc.biz.smp.usecase;
|
||||
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteResponse;
|
||||
|
||||
public interface DailyQuoteToolUseCase {
|
||||
@McpTool(name = "smp_quote_daily", title = "오늘의 명언 툴", description = "무작위로 영감을 주는 명언을 하나 가져옵니다.")
|
||||
@ToolHint(register = false, categoryKey = "smp", mappingId = "QUOTE_001")
|
||||
@GrowToolHint(categoryKey = "smp", mappingId = "QUOTE_001")
|
||||
DailyQuoteResponse getDailyQuote(DailyQuoteRequest req);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package io.shinhanlife.dap.mcc.biz.smp.usecase;
|
||||
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest;
|
||||
@@ -10,6 +10,6 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
|
||||
|
||||
public interface ExchangeRateToolUseCase {
|
||||
@McpTool(name = "smp_exchange_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)")
|
||||
@ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001")
|
||||
@GrowToolHint(categoryKey = "smp", mappingId = "EXCHANGE_001")
|
||||
ExchangeRateResponse getExchangeRate(ExchangeRateRequest req);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.TeamMemberRequest;
|
||||
|
||||
public interface TeamMemberUseCase {
|
||||
@McpTool(name = "smp_team_list", title = "신한라이프 MCP, TOOL 파트 구성원 조회", description = "신한라이프 MCP, TOOL 파트 구성원을 조회합니다.", annotations = @McpTool.McpAnnotations(openWorldHint = true))
|
||||
@ToolHint(register = false, requiresApproval = false, categoryKey = "smp", mappingId = "DIRECT0001")
|
||||
@GrowToolHint(requiresApproval = false, categoryKey = "smp", mappingId = "DIRECT0001")
|
||||
Object getTeamMember(TeamMemberRequest req);
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package io.shinhanlife.dap.mcc.biz.smp.usecase;
|
||||
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.*;
|
||||
|
||||
public interface WeatherToolUseCase {
|
||||
@McpTool(name = "smp_weather_inquiry", title = "날씨 조회 툴", description = "특정 도시의 현재 날씨, 온도, 풍속 정보를 조회합니다.")
|
||||
@ToolHint(register = false, categoryKey = "smp", mappingId = "WEATHER_001")
|
||||
@GrowToolHint(categoryKey = "smp", mappingId = "WEATHER_001")
|
||||
WeatherResponse getWeather(WeatherRequest req);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user