Merge remote-tracking branch 'origin/main'
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>>() { });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user