forked from kimhyungsik/ax_hub_mcp_tool
feat: integrate Spring AI ChatClient with dynamic ToolCallback for MCP tools
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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() + "\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
232
axhub-gateway/src/main/resources/static/chat.html
Normal file
232
axhub-gateway/src/main/resources/static/chat.html
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.axhub.biz.mcp.tool.dto
|
||||
* @className WeatherReq
|
||||
* @description 기상 조회 요청 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.07.14
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.07.14 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public record WeatherReq(
|
||||
@JsonProperty(required = true, value = "city")
|
||||
@JsonPropertyDescription("날씨를 조회할 도시 이름 (예: 서울, 부산, 제주)")
|
||||
String city
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.axhub.biz.mcp.tool.dto
|
||||
* @className WeatherRes
|
||||
* @description 기상 조회 응답 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.07.14
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.07.14 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public record WeatherRes(
|
||||
String city,
|
||||
double temperature,
|
||||
double windSpeed,
|
||||
String reportTime,
|
||||
String summary
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.WeatherReq;
|
||||
import io.shinhanlife.axhub.biz.mcp.tool.dto.WeatherRes;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.axhub.biz.mcp.tool.service
|
||||
* @className WeatherToolService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.07.14
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.07.14 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@McpTool(
|
||||
routingType = "DIRECT",
|
||||
categoryKey = "common"
|
||||
)
|
||||
public class WeatherToolService extends AbstractMcpToolService {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public WeatherToolService() {
|
||||
this.restClient = RestClient.create();
|
||||
}
|
||||
|
||||
@McpFunction(displayName = "날씨 조회 툴", name = "get_current_weather",
|
||||
description = "특정 도시의 실시간 날씨(기온, 풍속 등)를 오픈 기상 API를 통해 조회합니다.",
|
||||
prompt = "서울 날씨 알려줘, 부산 기온 알려줘 등 실시간 기상 조회",
|
||||
mappingId = "WEATHER_001"
|
||||
)
|
||||
public WeatherRes execute(WeatherReq req) {
|
||||
String city = req.city() != null ? req.city().trim() : "서울";
|
||||
|
||||
// 지역별 위경도 매핑 (간단한 예시)
|
||||
double lat = 37.566;
|
||||
double lon = 126.978;
|
||||
|
||||
if (city.contains("부산")) {
|
||||
lat = 35.179;
|
||||
lon = 129.075;
|
||||
} else if (city.contains("제주")) {
|
||||
lat = 33.499;
|
||||
lon = 126.531;
|
||||
} else if (city.contains("인천")) {
|
||||
lat = 37.456;
|
||||
lon = 126.705;
|
||||
}
|
||||
|
||||
try {
|
||||
String url = String.format("https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f¤t_weather=true", lat, lon);
|
||||
|
||||
log.info("[WeatherTool] 날씨 조회 요청 URL: {}", url);
|
||||
|
||||
String responseStr = restClient.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode response = mapper.readTree(responseStr);
|
||||
|
||||
if (response != null && response.has("current_weather")) {
|
||||
JsonNode current = response.get("current_weather");
|
||||
double temp = current.path("temperature").asDouble();
|
||||
double windSpeed = current.path("windspeed").asDouble();
|
||||
String time = current.path("time").asText();
|
||||
|
||||
int weatherCode = current.path("weathercode").asInt();
|
||||
String summary = parseWeatherCode(weatherCode);
|
||||
|
||||
String formattedTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
||||
|
||||
return new WeatherRes(city, temp, windSpeed, formattedTime, summary);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[WeatherTool] 날씨 API 연동 실패: {}", e.getMessage());
|
||||
return new WeatherRes(city, 0.0, 0.0, "", "날씨 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
|
||||
return new WeatherRes(city, 0.0, 0.0, "", "알 수 없는 응답입니다.");
|
||||
}
|
||||
|
||||
private String parseWeatherCode(int code) {
|
||||
if (code == 0) return "맑음 (Clear)";
|
||||
if (code >= 1 && code <= 3) return "구름조금/흐림 (Cloudy)";
|
||||
if (code >= 45 && code <= 48) return "안개 (Fog)";
|
||||
if (code >= 51 && code <= 67) return "비/이슬비 (Rain)";
|
||||
if (code >= 71 && code <= 77) return "눈 (Snow)";
|
||||
if (code >= 95) return "뇌우/천둥번개 (Thunderstorm)";
|
||||
return "알 수 없음 (Unknown)";
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ subprojects {
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
maven { url 'https://repo.spring.io/snapshot' }
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
|
||||
Reference in New Issue
Block a user