feat: Support manual execution of hidden tools via fallback routing

This commit is contained in:
jade
2026-07-08 16:09:45 +09:00
parent e8aabecc1a
commit 2b432c701a
4 changed files with 71 additions and 31 deletions

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.axhub.biz.mcp.gateway.service;
import io.shinhanlife.axhub.common.mcp.security.SecurityProperties;
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -36,8 +37,17 @@ public class ToolPlanner {
var toolMetadata = redisRegistryService.getTool(toolName);
if (toolMetadata == null) {
log.warn(" [Planner] 등록되지 않은 툴 요청: {}", toolName);
throw new RuntimeException("해당 툴(" + toolName + ")이 레지스트리에 존재하지 않습니다.");
log.warn(" [Planner] 등록되지 않은 툴 요청: {}. 레지스트리를 참조하지 않고 강제 연동 모드로 전환합니다.", toolName);
// 히든 툴(register=false)에 대한 강제 라우팅 폴백
String fallbackPodUrl = "http://tool-other:8084"; // 기본값 (get_template_file_url 등)
if (toolName.contains("email")) fallbackPodUrl = "http://tool-email:8083";
else if (toolName.contains("sms")) fallbackPodUrl = "http://tool-sms:8082";
toolMetadata = ToolMetadata.builder()
.toolName(toolName)
.integrationType("DIRECT")
.podUrl(fallbackPodUrl)
.build();
}
// 2-1. [신규] 도메인 그룹핑 기반 권한 검증

View File

@@ -234,6 +234,21 @@
<!-- Fields will be dynamically injected here -->
</div>
<!-- NEW: Manual Input Container for Hidden Tools -->
<div id="manualInputContainer" class="grid grid-cols-1 gap-4 mt-4 p-5 bg-slate-900/40 rounded-xl border border-rose-500/30 hidden">
<div class="col-span-full">
<h3 class="text-sm font-bold text-rose-400 mb-2 border-b border-rose-500/20 pb-2">히든 툴 직접 입력 (Manual Override)</h3>
</div>
<div class="group">
<label class="block text-xs font-bold text-slate-400 mb-2">실행할 툴 이름 (Tool Name)</label>
<input type="text" id="manualToolName" class="input-premium block w-full py-2 px-3 text-sm rounded-lg" value="get_template_file_url" placeholder="예: get_template_file_url">
</div>
<div class="group">
<label class="block text-xs font-bold text-slate-400 mb-2">파라미터 (JSON 형식)</label>
<textarea id="manualToolArgs" rows="3" class="input-premium block w-full py-2 px-3 text-sm rounded-lg font-mono" placeholder='{"templateId": "test"}'>{"templateId": "test_excel"}</textarea>
</div>
</div>
<div class="group pt-2">
<label for="userPrompt" class="flex items-center text-xs font-bold text-slate-400 mb-2 uppercase tracking-widest transition-colors group-focus-within:text-brand-400">
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
@@ -297,11 +312,6 @@
const tools = data.result?.tools || [];
if (tools.length === 0) {
toolSelect.innerHTML = '<option value="">등록된 툴이 없습니다</option>';
return;
}
const groupedTools = {};
tools.forEach(tool => {
const group = tool.domainGroup || '기타 그룹';
@@ -327,6 +337,17 @@
});
toolSelect.appendChild(optgroup);
});
// Add Manual Input Option
const optgroupManual = document.createElement('optgroup');
optgroupManual.label = '고급 (Advanced)';
const optionManual = document.createElement('option');
optionManual.value = 'MANUAL_INPUT';
optionManual.textContent = '🛠️ 비공개 툴 직접 입력 (Hidden Tool Test)';
optionManual.dataset.schema = "null";
optgroupManual.appendChild(optionManual);
toolSelect.appendChild(optgroupManual);
} catch (err) {
toolSelect.innerHTML = '<option value="">데이터 로드 실패</option>';
}
@@ -398,8 +419,15 @@
}
toolSelect.addEventListener('change', () => {
updatePrompt();
renderDynamicForm();
if (toolSelect.value === 'MANUAL_INPUT') {
document.getElementById('manualInputContainer').classList.remove('hidden');
dynamicFormContainer.classList.add('hidden');
document.getElementById('userPrompt').value = "숨겨진 툴 강제 호출 테스트";
} else {
document.getElementById('manualInputContainer').classList.add('hidden');
updatePrompt();
renderDynamicForm();
}
});
document.addEventListener('DOMContentLoaded', fetchTools);
@@ -429,6 +457,26 @@
return;
}
let finalToolName = toolSelect.value;
let finalArguments = {};
if (finalToolName === 'MANUAL_INPUT') {
finalToolName = document.getElementById('manualToolName').value.trim();
if (!finalToolName) {
alert("실행할 히든 툴의 이름을 입력해주세요.");
return;
}
try {
const argsVal = document.getElementById('manualToolArgs').value.trim();
finalArguments = argsVal ? JSON.parse(argsVal) : {};
} catch(err) {
alert("JSON 파라미터 형식이 올바르지 않습니다.");
return;
}
} else {
finalArguments = collectArguments();
}
const loadingOverlay = document.getElementById('loadingOverlay');
const resultContainer = document.getElementById('resultContainer');
const jsonResponse = document.getElementById('jsonResponse');
@@ -439,14 +487,12 @@
loadingOverlay.classList.add('active');
const actualArguments = collectArguments();
const payload = {
jsonrpc: "2.0",
method: "tools/call",
params: {
name: toolSelect.value,
arguments: actualArguments
name: finalToolName,
arguments: finalArguments
},
id: Math.floor(Math.random() * 10000)
};

View File

@@ -82,21 +82,8 @@ public class BusinessToolController {
return error;
}
// --- 비공개 툴(register=false)에 대한 강제 실행 접근 원천 차단 로직 ---
McpProperties.FunctionProp prop = null;
if (mcpProperties != null && mcpProperties.getFunctions() != null) {
prop = mcpProperties.getFunctions().get(targetFunctionAnnotation.name());
}
boolean isRegister = prop != null && prop.getRegister() != null ? prop.getRegister() : targetFunctionAnnotation.register();
if (!isRegister) {
log.warn("[Tool] 비공개 툴에 대한 강제 접근 시도 차단: {}", functionName);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
error.put("error", Map.of("code", -32001, "message", "접근이 차단된 비공개 툴입니다: " + functionName));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
// --- 비공개 툴(register=false)은 외부 노출(Redis)만 제외하고, 직접 실행은 허용하도록 변경 ---
// (기존 차단 로직 제거됨)
// 2. 파라미터 유효성 검증 (JSON Schema)
if (targetMethod.getParameterCount() > 0) {

View File

@@ -5,6 +5,3 @@ spring.profiles.active=local
# Suppress Kafka Connection Logs
logging.level.org.apache.kafka=ERROR
# MCP Function Register overrides
mcp.functions.get_template_file_url.register=true