From 16b5b81ed4c53dcddf752ff10a84d3d56d2cb705 Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 14 Jul 2026 23:35:39 +0900 Subject: [PATCH] =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 71 +++++++++++-------- .../ToolResponseGuardrailService.java | 20 ++++-- .../gateway/presentation/ChatController.java | 13 +++- .../presentation/McpRouterController.java | 11 ++- .../mcp/gateway/service/ExecuteService.java | 5 +- .../src/main/resources/application.properties | 2 +- .../presentation/BusinessToolController.java | 2 +- 7 files changed, 76 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 6d095a4..217633d 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@ Spring Boot 기반 AXHUB 관리자 백엔드 API 서버 및 MCP(Model Context Pr ## 아키텍처 개요 (Architecture Overview) -AXHUB Backend는 3개의 주요 애플리케이션으로 분리 운영됩니다: +AXHUB Backend는 크게 2개의 주요 애플리케이션 계층으로 분리 운영됩니다: -1. **AXHUB Admin (`AxHubAdminApplication`)**: 관리자 웹 화면을 위한 REST API 서버 -2. **MCP Gateway (`AxHubGatewayApplication`)**: 외부 LLM(Claude, GPT 등) 서버의 MCP 통신을 받아, 내부 Tool 서버들로 분배(라우팅)하는 허브 서버 (포트: 8081) -3. **MCP Tool (`AxHubToolApplication`)**: 실제 레거시 시스템(MCI, EAI 등)과 통신하여 비즈니스 로직(결제, 휴가신청 등)을 수행하는 어댑터 서버 (포트: 8082~8084 분산 구성 가능) +1. **MCP Gateway (`AxHubGatewayApplication`)**: 내장된 웹 UI(AI 챗봇, 스캐폴더) 제공 및 외부 LLM(Claude, GPT 등) 서버의 MCP 통신을 받아 내부 Tool 서버들로 분배(라우팅)하는 허브 서버 (포트: 8081) +2. **MCP Tool (`AxHubToolApplication`)**: 실제 레거시 시스템(MCI, EAI 등)과 통신하여 비즈니스 로직(결제, 휴가신청 등)을 수행하는 어댑터 서버 (포트: 8082~8085 등 분산 구성 가능) --- @@ -31,35 +30,50 @@ AXHUB Backend는 3개의 주요 애플리케이션으로 분리 운영됩니다: ## ▶ 실행 방법 (How to Run) -### 1. Gateway & Tool 서버 실행 (MCP 연동용) -- **Gateway 서버 기동:** - - `./gradlew bootRun -PmainClass=io.shinhanlife.AxHubGatewayApplication` (기본 포트: 8081) -- **Tool 서버 기동 (필요에 따라 N대 스케일 아웃 가능):** - - `./gradlew bootRun -PmainClass=io.shinhanlife.AxHubToolApplication --args="--server.port=8082"` - - Tool 서버가 기동되면 자동으로 Gateway(8081)에 자신을 등록(Auto-Registration)합니다. - - **(선택) 특정 Tool 그룹만 실행하기:** - - 업무 특성에 따라 세분화된 그룹에 속한 Tool만 띄우고 싶다면, 실행 인수에 `--mcp.tool.target=그룹명`을 추가합니다. - - **지원되는 그룹명:** - - `NOTIFICATION`: 이메일, SMS 발송 - - `CLAIM`: 청구 처리, 심사 상태 조회 - - `POLICY`: 증권 발행, 발행 가능 여부 조회 - - `HR`: 휴가 등록, 연차 갯수 조회 - - `CONTRACT`: 계약 상태, 계약 상세 조회 - - `CUSTOMER`: 고객 등급, 고객 상세 정보 조회 - - IntelliJ IDEA: `Run/Debug Configurations` `AxHubToolApplication` `Program arguments` 에 `--mcp.tool.target=NOTIFICATION` 입력 +### 1. Docker Compose를 이용한 전체 실행 (권장) +마이크로서비스 아키텍처 특성상 여러 개의 Tool Pod이 필요하므로, Docker Compose를 이용한 전체 실행을 권장합니다. + +```bash +# 전체 시스템(Gateway + Redis + 모든 Tool Pod) 빌드 및 백그라운드 실행 +./gradlew build -x test +docker compose up -d --build +``` +- **Gateway (Chat UI & 라우터)**: `http://localhost:8281` +- Gateway가 뜨면 내장된 챗봇 웹 UI(`http://localhost:8281/chat.html`)에 접속하여 바로 테스트할 수 있습니다. + +### 2. 로컬 개발 시 개별 실행 (IntelliJ / Gradle) +개발 중 특정 모듈만 띄워 디버깅해야 할 경우 아래와 같이 실행합니다. + +- **Gateway 서버 기동:** + - `./gradlew :axhub-gateway:bootRun` +- **Tool 서버 기동 (예: other 툴):** + - `./gradlew :axhub-tool-other:bootRun` + - Tool 서버가 기동되면 자동으로 Gateway에 자신을 등록(Auto-Registration)합니다. + -### 2. Admin 관리자 서버 실행 -- **Admin 서버 기동:** - - `./gradlew bootRun -PmainClass=io.shinhanlife.AxHubAdminApplication` (포트: 8080) --- -## 🤖 AI Agent 연동 아키텍처 (MCP & Agent Builder) +## 🤖 AI Agent 연동 아키텍처 (Spring AI & MCP) 본 시스템은 **투트랙(Two-Track) AI 연동 아키텍처**를 제공하여 로컬 개발 환경과 프로덕션 환경 모두를 완벽하게 지원합니다. -### 1. 로컬 코딩 AI (Antigravity, Cursor, Claude Desktop 등) 연동 -표준 MCP 통신(Stdio)을 요구하는 로컬 AI 에이전트를 위해 자바 기반의 브릿지 스크립트(`McpBridge.java`)를 내장하고 있습니다. 브릿지가 Stdio 요청을 HTTP로 변환하여 로컬 환경의 Gateway(포트: 8281)로 전달합니다. +### 1. 내장형 웹 챗봇 (Spring AI 기반) - NEW! 🎉 +가장 빠르고 직관적으로 AI 에이전트를 테스트할 수 있는 내장형 챗봇 화면을 제공합니다. Gateway 서버 자체에 **Spring AI (spring-ai-starter-mcp-server-webmvc)** 가 연동되어 있어 별도의 파이썬 스크립트나 외부 앱 없이도 즉각적인 테스트가 가능합니다. + +- **접속 방법**: Gateway(Docker) 기동 후 브라우저에서 `http://localhost:8281/chat.html` 접속 +- **동작 방식**: + 1. 사용자가 질문을 입력하면 내부 `ChatClient` (Gemini API 등)로 전송 + 2. Spring AI가 내부 레지스트리의 Tool 목록을 분석하여 필요한 Tool 탐색 + 3. LLM이 Tool 호출 판단 시, Gateway의 `ExecuteService`를 거쳐 Tool Pod의 기능을 직접 실행 + 4. 결과를 LLM이 다시 해석하여 사용자에게 자연어로 응답 + +### 2. 프로덕션 클라우드 AI (Google Cloud Agent Builder 등) 연동 +실제 라이브 서비스에서 동작하는 클라우드 Agent Builder는 REST API 기반의 OpenAPI Spec을 요구합니다. +`axhub-gateway`는 이미 **Agent Builder 규격의 REST API(`/mcp/api/v1/tools/call`)를 네이티브로 제공**하므로, 별도의 브릿지나 어댑터 없이 Endpoint URL과 Swagger(OpenAPI) 문서만 클라우드 콘솔에 등록하면 즉시 라이브 챗봇/에이전트로 서비스할 수 있습니다. + +### 3. (Legacy) 로컬 코딩 AI (Cursor, Claude Desktop 등) 연동 +표준 MCP 통신(Stdio)을 요구하는 로컬 AI 에이전트를 위해 자바 기반의 브릿지 스크립트(`McpBridge.java`)를 내장하고 있습니다. 브릿지가 Stdio 요청을 HTTP로 변환하여 로컬 환경의 Gateway로 전달합니다. - **설정 방법**: IDE의 `mcp_config.json` 설정 파일에 아래와 같이 등록합니다. ```json @@ -70,11 +84,6 @@ AXHUB Backend는 3개의 주요 애플리케이션으로 분리 운영됩니다: } } ``` -- **특정 카테고리 툴 필터링**: `McpBridge.java` 내부의 URI 파라미터(`?categoryKey=common`)를 수정하여 원하는 도메인의 툴만 선택적으로 AI에게 학습시킬 수 있습니다. - -### 2. 프로덕션 클라우드 AI (Google Cloud Agent Builder 등) 연동 -실제 라이브 서비스에서 동작하는 클라우드 Agent Builder는 REST API 기반의 OpenAPI Spec을 요구합니다. -`axhub-gateway`는 이미 **Agent Builder 규격의 REST API(`/mcp/api/v1/tools/call`)를 네이티브로 제공**하므로, 별도의 브릿지나 어댑터 없이 Endpoint URL과 Swagger(OpenAPI) 문서만 클라우드 콘솔에 등록하면 즉시 라이브 챗봇/에이전트로 서비스할 수 있습니다. --- diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java index e3481f0..50733fc 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java @@ -25,13 +25,19 @@ public class ToolResponseGuardrailService { public String validateAndWrap(ToolMetadata metadata, String requestId, JsonNode data) { validateData(metadata.getName(), data); - ObjectNode response = json.createObjectNode(); - response.put("success", true); - response.put("toolName", metadata.getName()); - response.put("requestId", requestId); - response.put("source", "registered-tool-server"); - response.set("data", data); - response.set("answerPolicy", answerPolicy()); + ObjectNode response; + if (data.isObject() && data.has("success") && data.has("data")) { + response = ((ObjectNode) data).deepCopy(); + response.set("answerPolicy", answerPolicy()); + } else { + response = json.createObjectNode(); + response.put("success", true); + response.put("toolName", metadata.getName()); + response.put("requestId", requestId); + response.put("source", "registered-tool-server"); + response.set("data", data); + response.set("answerPolicy", answerPolicy()); + } try { return json.writerWithDefaultPrettyPrinter().writeValueAsString(response); diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java index d937fff..bb98715 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java @@ -65,9 +65,18 @@ public class ChatController { error -> { log.error("[Real AI Chat] 스트리밍 중 에러 발생", error); try { - emitter.send("\n[에러 발생: " + error.getMessage() + "]"); + String errorMsg = error.getMessage(); + if (errorMsg != null && (errorMsg.contains("timeout") || errorMsg.contains("OpenAIIoException"))) { + emitter.send("\n\n⚠️ **요청 시간이 초과되었습니다.** (현재 여러 개의 도구를 분석하느라 모델의 응답이 지연되었습니다. 해당하는 도구가 없거나 너무 복잡한 요청일 수 있습니다.)"); + } else if (errorMsg != null && errorMsg.contains("503")) { + emitter.send("\n\n⚠️ **AI 모델 서버 혼잡 (503)**: 현재 AI 모델을 제공하는 서버에 일시적으로 접속자가 많아 지연이 발생하고 있습니다. 잠시 후 다시 시도해 주세요."); + } else if (errorMsg != null && errorMsg.contains("429")) { + emitter.send("\n\n⚠️ **API 사용량 초과 (429)**: 현재 사용 중인 Gemini API(무료 티어)의 일일 또는 분당 요청 한도를 초과했습니다. 잠시 후 다시 시도하시거나 API 플랜을 확인해 주세요."); + } else { + emitter.send("\n[에러 발생: " + errorMsg + "]"); + } } catch (Exception ignore) {} - emitter.completeWithError(error); + emitter.complete(); // 클라이언트 측에서 네트워크 에러로 처리하지 않도록 정상 종료 }, () -> { log.info("[Real AI Chat] 스트리밍 완료"); diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/McpRouterController.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/McpRouterController.java index 1601968..c473b07 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/McpRouterController.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/McpRouterController.java @@ -73,13 +73,18 @@ public class McpRouterController { try { Object result = executeService.execute(payload, tenantId); + io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse response = new io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse(); + response.setJsonrpc("2.0"); + response.setId(payload.containsKey("id") ? String.valueOf(payload.get("id")) : UUID.randomUUID().toString()); + response.setResult(result); + try { - log.info("[MCP Gateway -> Admin UI] 동적 툴 실행 결과 반환: {}", objectMapper.writeValueAsString(result)); + log.info("[MCP Gateway -> Admin UI] 동적 툴 실행 결과 반환: {}", objectMapper.writeValueAsString(response)); } catch (Exception ex) { - log.info("[MCP Gateway -> Admin UI] 동적 툴 실행 결과 반환: {}", result); + log.info("[MCP Gateway -> Admin UI] 동적 툴 실행 결과 반환: {}", response); } - return ResponseEntity.ok(result); + return ResponseEntity.ok(response); } catch (SecurityException se) { log.warn(" [보안 차단] 권한 오류: {}", se.getMessage()); return ResponseEntity.status(403).body(Map.of("error", se.getMessage())); diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java index b1e7828..9947c62 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java @@ -138,9 +138,8 @@ public class ExecuteService { // Apply Output Guardrail String wrappedResponse = responseGuardrail.validateAndWrap(metadata, context.requestId(), (com.fasterxml.jackson.databind.JsonNode) result); - // Format the result - ToolExecutionResult formattedResult = resultFormatter.fromRawResponse(metadata.getName(), wrappedResponse); - result = formattedResult; + // Format the result (Restore legacy raw format as Map/List for backward compatibility) + result = objectMapper.readValue(wrappedResponse, Object.class); } long elapsedMillis = elapsedMillis(startedAt); diff --git a/axhub-gateway/src/main/resources/application.properties b/axhub-gateway/src/main/resources/application.properties index 3e95252..58dcf30 100644 --- a/axhub-gateway/src/main/resources/application.properties +++ b/axhub-gateway/src/main/resources/application.properties @@ -42,7 +42,7 @@ 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.api-key=AQ.Ab8RN6KFZggsQf8iooY1v_3h3vp2TIjiYB54dV4Yay3vVKEMtg 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 diff --git a/axhub-tool-core/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/presentation/BusinessToolController.java b/axhub-tool-core/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/presentation/BusinessToolController.java index bfe12ff..68e8df0 100644 --- a/axhub-tool-core/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/presentation/BusinessToolController.java +++ b/axhub-tool-core/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/presentation/BusinessToolController.java @@ -212,7 +212,7 @@ public class BusinessToolController { resultPayload.put("error_message", null); resultPayload.put("elapsed_ms", elapsed); resultPayload.put("truncated", false); - int originalSize = 0; + int originalSize = 0; try { if (methodResult instanceof Map && ((Map) methodResult).containsKey("legacy_response")) { Object legacyResp = ((Map) methodResult).get("legacy_response");