feat: integrate Spring AI ChatClient with dynamic ToolCallback for MCP tools

This commit is contained in:
jade
2026-07-14 22:09:26 +09:00
parent 4fa1cdc954
commit e156fb4a70
8 changed files with 552 additions and 1 deletions

View File

@@ -10,7 +10,7 @@ dependencies {
// Spring AI MCP Server
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
// MyBatis & DB
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

View File

@@ -0,0 +1,152 @@
package io.shinhanlife.axhub.biz.mcp.gateway.presentation;
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
import io.shinhanlife.axhub.biz.mcp.gateway.service.ExecuteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Flux;
import java.util.*;
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ExecuteService executeService;
private final ObjectMapper objectMapper;
private final RedisRegistryService registryService;
private final ChatClient.Builder chatClientBuilder;
@PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestBody Map<String, String> request) {
String message = request.getOrDefault("message", "").trim();
log.info("[Real AI Chat] 사용자의 메시지 수신: {}", message);
SseEmitter emitter = new SseEmitter(120000L); // 120 seconds timeout
try {
List<ToolCallback> callbacks = new ArrayList<>();
for (ToolMetadata meta : registryService.getAllTools()) {
if (Boolean.TRUE.equals(meta.getVisible())) {
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper));
}
}
ChatClient chatClient = chatClientBuilder
.defaultSystem("You are AX HUB Assistant, a highly capable enterprise AI agent. You must use the provided tools to answer user questions when necessary. Always answer politely in Korean.")
.build();
Flux<String> responseStream = chatClient.prompt()
.user(message)
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools()
.stream()
.content();
responseStream.subscribe(
chunk -> {
try {
if (chunk != null) {
emitter.send(chunk);
}
} catch (Exception e) {
emitter.completeWithError(e);
}
},
error -> {
log.error("[Real AI Chat] 스트리밍 중 에러 발생", error);
try {
emitter.send("\n[에러 발생: " + error.getMessage() + "]");
} catch (Exception ignore) {}
emitter.completeWithError(error);
},
() -> {
log.info("[Real AI Chat] 스트리밍 완료");
emitter.complete();
}
);
} catch (Exception e) {
log.error("[Real AI Chat] 초기화 중 에러 발생", e);
try {
emitter.send("초기화 중 에러가 발생했습니다: " + e.getMessage());
} catch (Exception ignore) {}
emitter.completeWithError(e);
}
return emitter;
}
private static class DynamicMcpToolCallback implements ToolCallback {
private final ToolMetadata metadata;
private final ExecuteService executeService;
private final ObjectMapper objectMapper;
private final ToolDefinition toolDefinition;
public DynamicMcpToolCallback(ToolMetadata metadata, ExecuteService executeService, ObjectMapper objectMapper) {
this.metadata = metadata;
this.executeService = executeService;
this.objectMapper = objectMapper;
String schema = "{\"type\":\"object\",\"properties\":{}}";
try {
if (metadata.getParametersSchema() != null) {
schema = objectMapper.writeValueAsString(metadata.getParametersSchema());
}
} catch (Exception e) {
log.warn("Schema parsing error for tool: {}", metadata.getName());
}
this.toolDefinition = ToolDefinition.builder()
.name(metadata.getName().replaceAll("[^a-zA-Z0-9_-]", "_"))
.description(metadata.getDescription() != null ? metadata.getDescription() : "No description provided")
.inputSchema(schema)
.build();
}
@Override
public ToolDefinition getToolDefinition() {
return this.toolDefinition;
}
@Override
public String call(String toolInput) {
log.info("[Function Calling] LLM이 '{}' 툴을 호출했습니다. 입력값: {}", metadata.getName(), toolInput);
try {
Map<String, Object> payload = new HashMap<>();
payload.put("jsonrpc", "2.0");
payload.put("method", "tools/call");
payload.put("id", UUID.randomUUID().toString());
Map<String, Object> params = new HashMap<>();
params.put("name", metadata.getName());
if (toolInput != null && !toolInput.trim().isEmpty()) {
Map<String, Object> arguments = objectMapper.readValue(toolInput, Map.class);
params.put("arguments", arguments);
} else {
params.put("arguments", new HashMap<>());
}
payload.put("params", params);
Object result = executeService.execute(payload, "default");
String jsonResult = objectMapper.writeValueAsString(result);
log.info("[Function Calling] '{}' 툴 실행 완료. 결과: {}", metadata.getName(), jsonResult);
return jsonResult;
} catch (Exception e) {
log.error("[Function Calling] '{}' 툴 실행 중 에러", metadata.getName(), e);
return "{\"error\": \"" + e.getMessage() + "\"}";
}
}
}
}

View File

@@ -40,3 +40,10 @@ mcp.max-pages-per-call=3
mcp.max-stream-bytes=1048576
mcp.max-response-bytes-from-tool=5242880
mcp.tool-registry-sync-interval-millis=5000
# Spring AI OpenAI / Gemini API Config
spring.ai.openai.api-key=AIzaSyB4fAgAjF9lL4CiiH4yMevD9qE6wMyWZO0
spring.ai.openai.base-url=https://generativelanguage.googleapis.com/v1beta/openai/
spring.ai.openai.chat.options.model=gemini-flash-latest
spring.ai.openai.chat.options.temperature=0.3

View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AX HUB - ChatClient</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #0f1115; color: #e2e8f0; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.message-enter { animation: fadeInUp 0.3s ease-out forwards; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.typing-dot { animation: typing 1.4s infinite ease-in-out; fill: #94a3b8; }
.typing-dot:nth-child(1) { animation-delay: 0s; }
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
</style>
</head>
<body class="h-screen flex flex-col items-center justify-center p-4">
<!-- Chat Container -->
<div class="w-full max-w-3xl h-[85vh] flex flex-col bg-[#16181d] rounded-2xl shadow-2xl overflow-hidden border border-white/5 relative">
<!-- Header -->
<div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-[#16181d] z-10">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<div>
<h1 class="text-sm font-semibold tracking-wide text-white">AX HUB Assistant</h1>
<p class="text-[11px] text-slate-500">Powered by Mock Engine & MCP Tools</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span class="text-xs text-slate-400">Online</span>
</div>
</div>
<!-- Chat Area -->
<div id="chat-box" class="flex-1 overflow-y-auto p-6 flex flex-col gap-6 scrollbar-hide scroll-smooth">
<!-- System Greeting -->
<div class="flex gap-4 message-enter">
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex-shrink-0 flex items-center justify-center mt-1 border border-emerald-500/20">
<svg class="w-4 h-4 text-emerald-400" 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>
</div>
<div class="flex flex-col gap-1 max-w-[80%]">
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm">
안녕하세요! 저는 AX HUB Assistant입니다. <br>
채팅을 통해 MCP(도구)들을 직접 호출해보세요. <br><br>
💡 <strong>예시:</strong> "오늘 서울 날씨 어때?"
</div>
</div>
</div>
</div>
<!-- Input Area -->
<div class="p-4 bg-[#16181d] border-t border-white/5 relative">
<div class="relative flex items-center w-full max-w-full mx-auto group">
<input type="text" id="chat-input" placeholder="메시지를 입력하세요... (예: 서울 날씨 어때?)"
class="w-full bg-[#1e2128] text-slate-200 text-sm px-5 py-4 rounded-xl border border-white/5 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all placeholder-slate-600 pr-12 shadow-inner"
onkeypress="handleKeyPress(event)">
<button onclick="sendMessage()" class="absolute right-2 p-2 rounded-lg bg-emerald-500 hover:bg-emerald-400 text-white transition-colors flex items-center justify-center shadow-lg shadow-emerald-500/20">
<svg class="w-4 h-4 translate-x-[1px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
</button>
</div>
</div>
</div>
<!-- 로딩 인디케이터 템플릿 -->
<template id="loading-template">
<div class="flex gap-4 message-enter" id="loading-indicator">
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex-shrink-0 flex items-center justify-center mt-1 border border-emerald-500/20">
<svg class="w-4 h-4 text-emerald-400" 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>
</div>
<div class="flex flex-col gap-1 max-w-[80%]">
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
<div class="bg-[#1e2128] px-5 py-4 rounded-2xl rounded-tl-sm border border-white/5 flex items-center gap-1 shadow-sm h-[48px]">
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle class="typing-dot" cx="4" cy="12" r="3"/>
<circle class="typing-dot" cx="12" cy="12" r="3"/>
<circle class="typing-dot" cx="20" cy="12" r="3"/>
</svg>
</div>
</div>
</div>
</template>
<script>
const chatBox = document.getElementById('chat-box');
const chatInput = document.getElementById('chat-input');
const loadingTemplate = document.getElementById('loading-template');
let isLoading = false;
function handleKeyPress(e) {
if (e.key === 'Enter') {
sendMessage();
}
}
function appendUserMessage(text) {
const div = document.createElement('div');
div.className = 'flex gap-4 justify-end message-enter';
div.innerHTML = `
<div class="flex flex-col gap-1 max-w-[80%] items-end">
<span class="text-xs text-slate-500 mr-1 font-medium">You</span>
<div class="bg-emerald-500/10 px-5 py-3.5 rounded-2xl rounded-tr-sm text-sm text-emerald-50 leading-relaxed border border-emerald-500/20 shadow-sm whitespace-pre-wrap">${escapeHtml(text)}</div>
</div>
`;
chatBox.appendChild(div);
scrollToBottom();
}
function appendBotMessageContainer() {
const div = document.createElement('div');
div.className = 'flex gap-4 message-enter';
div.innerHTML = `
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex-shrink-0 flex items-center justify-center mt-1 border border-emerald-500/20">
<svg class="w-4 h-4 text-emerald-400" 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>
</div>
<div class="flex flex-col gap-1 max-w-[80%]">
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm whitespace-pre-wrap bot-text"></div>
</div>
`;
chatBox.appendChild(div);
scrollToBottom();
return div.querySelector('.bot-text');
}
function showLoading() {
const clone = loadingTemplate.content.cloneNode(true);
chatBox.appendChild(clone);
scrollToBottom();
}
function hideLoading() {
const loadingIndicator = document.getElementById('loading-indicator');
if (loadingIndicator) {
loadingIndicator.remove();
}
}
function scrollToBottom() {
chatBox.scrollTo({ top: chatBox.scrollHeight, behavior: 'smooth' });
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isLoading) return;
// 1. 유저 메시지 추가
appendUserMessage(text);
chatInput.value = '';
isLoading = true;
// 2. 로딩 애니메이션
showLoading();
try {
// 3. 백엔드 통신 (SSE Stream)
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text })
});
if (!response.ok) throw new Error('Network response was not ok');
// 4. 로딩 숨기고 봇 응답 컨테이너 생성
hideLoading();
const textContainer = appendBotMessageContainer();
// 5. 스트림 읽기
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5);
textContainer.innerHTML += escapeHtml(data);
scrollToBottom();
}
}
}
} catch (error) {
console.error(error);
hideLoading();
const textContainer = appendBotMessageContainer();
textContainer.innerHTML = "서버와의 통신에 실패했습니다. Gateway가 켜져있는지 확인해주세요.";
} finally {
isLoading = false;
}
}
</script>
</body>
</html>