Merge remote-tracking branch 'origin/main'
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
Boram
2026-08-11 16:23:03 +09:00
130 changed files with 2463 additions and 586 deletions

View File

@@ -1,39 +1,34 @@
plugins {
// Gateway를 독립 실행 가능한 Spring Boot JAR로 생성합니다.
id 'org.springframework.boot'
}
dependencies {
// Tool Registry, MCP 공통 처리, 보안/Schema 유틸리티를 공통 라이브러리에서 가져옵니다.
implementation project(':dap-was-lib')
// Gateway REST API와 관리 화면의 HTTP 요청을 처리합니다.
implementation 'org.springframework.boot:spring-boot-starter-web'
// Tool Registry 및 분산 캐시 연동에 사용합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Spring AI MCP Server
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-openai:2.0.0'
// MyBatis & DB
// Tool/Registry 관련 DB 조회와 MyBatis Mapper 실행에 사용합니다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
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'
// ChatClient를 통해 OpenAI/OpenRouter 호환 모델을 호출합니다.
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
// 로컬 개발·테스트용 인메모리 DB입니다. 운영 DB에는 사용하지 않습니다.
runtimeOnly 'com.h2database:h2'
// SQL 로그를 확인하기 위한 JDBC 프록시입니다.
implementation 'p6spy:p6spy:3.9.1'
// MapStruct
implementation "org.mapstruct:mapstruct:1.5.5.Final"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"
annotationProcessor "org.mapstruct:mapstruct-processor:1.5.5.Final"
// implementation 'com.networknt:json-schema-validator:1.4.0' // Spring AI 내장 버전과 충돌 방지를 위해 주석 처리
// Gateway REST API 문서와 Swagger UI를 제공합니다.
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}

View File

@@ -75,9 +75,9 @@ public class ChatController {
}
}
String selectedModel = request.getOrDefault("model", "gemini-flash-latest").trim();
String selectedModel = request.getOrDefault("model", "cohere/north-mini-code:free").trim();
if (selectedModel.isEmpty()) {
selectedModel = "gemini-flash-latest";
selectedModel = "cohere/north-mini-code:free";
}
// 1. gemini-flash-latest 선택 시 OpenRouter의 Google Gemma 4 모델로 라우팅
@@ -93,9 +93,9 @@ public class ChatController {
Flux<String> responseStream = activeChatClient.prompt()
.user(message)
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools()
.toolCallbacks(callbacks.toArray(new ToolCallback[0]))
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(selectedModel))
.model(selectedModel).build())
.stream()
.content();

View File

@@ -113,11 +113,16 @@ public class McpRouterController {
}
private List<ToolMetadata> fetchAllActiveTools() {
List<ToolMetadata> activeTools = redisRegistryService.getAllTools()
.stream()
.filter(ToolMetadata::getVisible)
.collect(Collectors.toList());
List<ToolMetadata> activeTools = new ArrayList<>();
try {
activeTools.addAll(redisRegistryService.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.");
}
Set<String> knownTools = activeTools.stream()
.map(ToolMetadata::getUid)
.collect(Collectors.toSet());

View File

@@ -18,6 +18,8 @@ package io.shinhanlife.dap.mcg.presentation;
import io.shinhanlife.dap.lib.util.PodScaffolder;
import io.shinhanlife.dap.lib.util.ToolScaffolder;
import io.shinhanlife.dap.lib.util.ToolSourceUpdater;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -66,8 +68,13 @@ public class ScaffoldingController {
String clientSystemCode = req.get("clientSystemCode");
String inputSchemaResource = req.get("inputSchemaResource");
String outputSchemaResource = req.get("outputSchemaResource");
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));
}
return ToolScaffolder.scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, date, register, clientSystemCode, inputSchemaResource, outputSchemaResource);
return ToolScaffolder.scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, date, register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields);
} catch (Exception e) {
return "오류 발생: " + e.getMessage();
}
@@ -107,4 +114,11 @@ public class ScaffoldingController {
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
}
}
private List<ToolScaffolder.FieldDefinition> parseFields(String source) throws Exception {
if (source == null || source.isBlank()) {
return List.of();
}
return new ObjectMapper().readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
}
}

View File

@@ -81,7 +81,7 @@ public class ToolPlanner {
}
// 2-1. [신규] 도메인 그룹핑 기반 권한 검증
if (tenantId != null && toolMetadata.getCategoryKey() != null) {
if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) {
String normalizedTenantId = tenantId.toLowerCase();
List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId);

View File

@@ -179,7 +179,7 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
log.info("Message sent to session handler");
if (emitter != null && !map.containsKey("id")) {
// emitter.complete();
emitter.complete();
log.info("Completed emitter for notification (disabled for keep-alive)");
}
@@ -220,7 +220,7 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
// Custom 프로토콜: 1회 요청당 1응답 후 종료 (스트림을 닫아버림)
// 클라이언트가 한 번의 POST 후 응답을 받고 연결을 끊기 때문
// this.emitter.complete();
this.emitter.complete();
}
} catch (Exception e) {
log.error("Error sending message to SSE emitter", e);

View File

@@ -55,6 +55,7 @@ public class DynamicMcpServerManager {
getOrCreateServer("email");
getOrCreateServer("oth");
getOrCreateServer("sample");
getOrCreateServer("smp"); // smp 카테고리도 기본적으로 항상 열어두도록 추가
}
private McpSyncServer getOrCreateServer(String categoryKey) {
@@ -68,7 +69,7 @@ public class DynamicMcpServerManager {
CustomWebMvcSseServerTransportProvider transport = new CustomWebMvcSseServerTransportProvider(ssePath, msgPath, objectMapper);
McpSyncServer newServer = McpServer.sync(transport)
.serverInfo("DAP-Gateway-" + key, "1.0.0")
.serverInfo("dap-was-" + key, "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.build();

View File

@@ -51,13 +51,14 @@ public class RegistryMcpToolSpecificationFactory {
McpSchema.Tool tool = McpSchema.Tool.builder()
.name(entry.getName())
.description(description(entry))
.inputSchema(inputSchema(entry))
.annotations(McpSchema.ToolAnnotations.builder()
.readOnlyHint(Boolean.TRUE.equals(entry.getReadOnlyHint()))
.destructiveHint(Boolean.TRUE.equals(entry.getDestructiveHint()))
.idempotentHint(Boolean.TRUE.equals(entry.getIdempotentHint()))
.openWorldHint(Boolean.TRUE.equals(entry.getOpenWorldHint()))
.build())
.inputSchema(toJsonSchema(inputSchema(entry)))
.annotations(new McpSchema.ToolAnnotations(
entry.getDisplayName(),
entry.getReadOnlyHint(),
entry.getDestructiveHint(),
entry.getIdempotentHint(),
entry.getOpenWorldHint(),
null))
.build();
return McpServerFeatures.SyncToolSpecification.builder()
@@ -147,6 +148,20 @@ public class RegistryMcpToolSpecificationFactory {
return schema;
}
@SuppressWarnings("unchecked")
private McpSchema.JsonSchema toJsonSchema(Map<String, Object> schema) {
return new McpSchema.JsonSchema(
String.valueOf(schema.getOrDefault("type", "object")),
schema.get("properties") instanceof Map<?, ?> properties
? (Map<String, Object>) properties : Map.of(),
schema.get("required") instanceof List<?> required
? (List<String>) required : List.of(),
schema.get("additionalProperties") instanceof Boolean additionalProperties
? additionalProperties : Boolean.TRUE,
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
schema.get("definitions") instanceof Map<?, ?> definitions
? (Map<String, Object>) definitions : Map.of());
}
private String description(ToolMetadata entry) {
return entry.getDescription() == null || entry.getDescription().isBlank()
? entry.getName() + " Tool"

View File

@@ -13,8 +13,9 @@ spring:
api-key: ${OPENROUTER_API_KEY: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}
base-url: https://openrouter.ai/api/v1
chat:
completions-path: /chat/completions
options:
model: google/gemma-4-31b-it:free
model: cohere/north-mini-code:free
temperature: 0.3
server:
@@ -43,6 +44,8 @@ mcp:
tenant-domains:
TESTER-DEV: ALL
system: ALL
AUTO-TESTER: ALL
auto-tester: ALL
mybatis:
mapper-locations: classpath:mapper/**/*.xml

View File

@@ -495,6 +495,29 @@
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Input Fields (JSON)</label>
<div class="d-flex gap-1">
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="loadFieldExample('inputFields')">예제 넣기</button>
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="copyFieldJson('inputFields')">복사</button>
</div>
</div>
<textarea id="inputFields" class="form-control" name="inputFields" rows="4" placeholder='[{"name":"employeeId","type":"String","description":"Employee ID","example":"EMP10001","required":true}]'></textarea>
</div>
<div class="col-md-6 mt-3 mt-md-0">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Output Fields (JSON)</label>
<div class="d-flex gap-1">
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="loadFieldExample('outputFields')">예제 넣기</button>
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="copyFieldJson('outputFields')">복사</button>
</div>
</div>
<textarea id="outputFields" class="form-control" name="outputFields" rows="4" placeholder='[{"name":"employeeName","type":"String","description":"Employee name","example":"Hong Gildong","required":true}]'></textarea>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label">Target Module</label>
@@ -685,6 +708,33 @@
handleFormSubmit('podForm', '/api/v1/scaffold/pod');
handleFormSubmit('toolForm', '/api/v1/scaffold/tool');
const fieldExamples = {
inputFields: [
{ name: 'employeeId', type: 'String', description: 'Employee identifier', example: 'EMP10001', required: true },
{ name: 'page', type: 'Integer', description: 'Page number', example: '1', required: false }
],
outputFields: [
{ name: 'employeeName', type: 'String', description: 'Employee name', example: 'Hong Gildong', required: true }
]
};
function loadFieldExample(fieldId) {
document.getElementById(fieldId).value = JSON.stringify(fieldExamples[fieldId], null, 2);
}
async function copyFieldJson(fieldId) {
const textarea = document.getElementById(fieldId);
const value = textarea.value.trim() || JSON.stringify(fieldExamples[fieldId], null, 2);
try {
await navigator.clipboard.writeText(value);
} catch (error) {
textarea.value = value;
textarea.select();
document.execCommand('copy');
textarea.setSelectionRange(0, 0);
}
}
function loadToolList() {
const tbody = document.getElementById('toolListBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-5 text-muted">Loading data...</td></tr>';

View File

@@ -85,11 +85,11 @@
</div>
<div class="flex items-center gap-4">
<select id="model-select" class="bg-[#1e2128] text-slate-300 text-xs px-3 py-1.5 rounded-lg border border-white/10 focus:outline-none focus:border-emerald-500/50 cursor-pointer">
<option value="inclusionai/ling-3.0-flash:free" selected>Ling 3.0 Flash ⭐ (기본 - OpenRouter)</option>
<option value="inclusionai/ling-3.0-flash:free">Ling 3.0 Flash (무료 - 현재 제공 상태에 따라 제한될 수 있음)</option>
<option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option>
<option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option>
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron Nano 30B (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free">Cohere North Mini (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free" selected>Cohere North Mini (기본 · 무료 - OpenRouter)</option>
<option value="gemini-flash-latest">Gemini 1.5 Flash (직접 API - Google)</option>
</select>

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.mcg.presentation;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.http.ResponseEntity;
class McpRouterControllerTest {
@Test
void returnsEmptyToolListWhenRedisIsUnavailableAndNoFallbackIsConfigured() {
RedisRegistryService registryService = org.mockito.Mockito.mock(RedisRegistryService.class);
when(registryService.getAllTools()).thenThrow(new RedisConnectionFailureException("Redis unavailable"));
McpRouterController controller = new McpRouterController(
registryService,
org.mockito.Mockito.mock(ExecuteService.class),
org.mockito.Mockito.mock(SecurityProperties.class),
new ObjectMapper(),
new GatewayFallbackProperties());
ResponseEntity<JsonRpcResponse> response = assertDoesNotThrow(() -> controller.listTools(null));
Map<?, ?> result = (Map<?, ?>) response.getBody().getResult();
assertEquals(List.of(), result.get("tools"));
}
}