MCP 챗봇 TDD

Background

목표가 알림·종목 리스트·주문·보유 종목 기능을 구축했다. 각 기능은 독립적인 REST API로 존재하며, 사용자는 화면을 이동하며 개별 기능을 사용해야 한다. 이 모든 기능을 Spring AI MCP Tool로 통합하고, 로컬 claude -p CLI가 오케스트레이션하는 자연어 챗봇을 제공한다. Anthropic API 키 없이 로컬 Claude Code CLI만으로 동작한다.

Overview

Spring Boot 백엔드에 Spring AI MCP Server를 추가한다 (Anthropic ChatClient 불필요). 기존 UseCase들을 래핑한 @Tool 메서드를 노출하고, ChatApiController(애그리게이터)가 claude -p --mcp-config stock-mcp.json을 subprocess로 실행해 응답을 스트리밍한다. 웹 서치는 stock-mcp.json에 외부 MCP 서버(Brave/Tavily) 항목을 추가해 Claude가 직접 호출하게 한다.

Terminology

용어정의
MCP (Model Context Protocol)LLM이 외부 Tool을 호출하기 위한 표준 프로토콜
Spring AI MCP ServerSpring Boot 위에서 MCP 프로토콜을 구현하는 서버 모듈
claude -pClaude Code CLI의 비대화형 출력 모드 (애그리게이터가 subprocess로 실행)
--mcp-configclaude -p에 MCP 서버 JSON 설정 파일을 지정하는 플래그
--output-format stream-jsonclaude -p의 스트리밍 JSON 출력 모드
애그리게이터ChatApiController — 채팅 요청을 받아 claude -p를 실행하고 응답을 스트리밍
SSEServer-Sent Events — MCP 웹 트랜스포트

Define Problem

AS-IS

  • 종목 조회·주문·알림 기능이 REST API로 분리돼 있어 단일 흐름 지원 불가.
  • AI 통합 없이 수동 탐색만 가능.
  • 시장 뉴스를 앱 내에서 조회할 수 없어 별도 검색이 필요.

TO-BE

  • 자연어 입력 한 번으로 Tool 자동 선택·실행·결과 요약.
  • Anthropic API 키 없이 로컬 Claude Code CLI로 동작 — 비용 0.
  • MCP Server가 Claude Desktop 등 외부 MCP 클라이언트와도 연동.
  • stock-mcp.json에 웹 서치 MCP 항목 추가로 최신 뉴스·공시 통합.

Possible Solutions

벤치마킹 참조 제품

제품명카테고리참조 패턴
Claude Desktop + MCPAI 클라이언트MCP Tool 호출 패턴
Claude Code -p 모드CLI 애그리게이터--mcp-config로 로컬 MCP 연결
PerplexityAI 검색웹 서치 + LLM 합성 응답

방안 비교

방안설명왜 채택미채택 대안
claude -p + Spring AI MCP Server (선택)Spring Boot가 MCP 서버 역할, 애그리게이터가 claude -p subprocess 실행Anthropic API 키 불필요. 로컬 Claude Code 재사용. 빌드 의존성 최소Spring AI ChatClient (Anthropic API 키 필요, 유료)
LangChain4jJava LLM 프레임워크API 키 필요, 커뮤니티 작음
Python FastAPI 애그리게이터별도 Python 서버에서 claude -p 실행스택 분리 비용 높음, Spring Boot 단일 프로세스로 충분

Detail Design

전체 흐름

[FE Chat UI]
   ↓ POST /api/chat/message {sessionId, message}
[ChatApiController (Spring Boot :8080) — 애그리게이터]
   ↓ ProcessBuilder: claude -p "{history + message}"
                      --mcp-config /etc/stock-mcp.json
                      --output-format stream-json
[claude -p (로컬 Claude Code CLI)]
   ↓ MCP Tool call (SSE)              ↓ 웹 서치 MCP call
[/mcp/sse (Spring Boot :8080)]    [Brave/Tavily MCP 서버]
   StockMcpTools
   WatchlistMcpTools
   AlertMcpTools
   OrderMcpTools
   ↓
[기존 UseCases → MySQL / Toss API]

MCP 설정 파일 (stock-mcp.json)

{
  "mcpServers": {
    "stock": {
      "type": "sse",
      "url": "http://localhost:8080/mcp/sse"
    },
    "brave-search": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-brave-search"],
      "env": { "BRAVE_API_KEY": "<key>" }
    }
  }
}

Brave 대신 Tavily MCP(@modelcontextprotocol/server-tavily)도 동일 방식으로 교체 가능.

레이어 구조

presentation/
  ai/
    ChatApiController.kt          — POST /api/chat/message (애그리게이터)
    StockMcpTools.kt              — 종목 도메인 @Tool
    WatchlistMcpTools.kt          — 관심종목 도메인 @Tool
    AlertMcpTools.kt              — 알림 도메인 @Tool
    OrderMcpTools.kt              — 주문 도메인 @Tool
common/
  config/
    McpServerConfig.kt            — MCP Server 빈 설정 (Anthropic 없음)
resources/
  stock-mcp.json                  — claude -p 에 전달할 MCP 서버 설정

ChatApiController 구현 방향

@RestController
class ChatApiController(
    @Value("\${chat.mcp-config-path}") private val mcpConfigPath: String,
    @Value("\${chat.claude-path:claude}") private val claudePath: String,
) {
    private val sessionHistory = ConcurrentHashMap<String, MutableList<String>>()
 
    @PostMapping("/api/chat/message", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun chat(@RequestBody @Valid request: ChatRequest): SseEmitter {
        val emitter = SseEmitter(120_000L)
        val history = sessionHistory.getOrPut(request.sessionId) { mutableListOf() }
        val fullPrompt = buildPrompt(history, request.message)
 
        Thread {
            try {
                val process = ProcessBuilder(
                    claudePath, "-p", fullPrompt,
                    "--mcp-config", mcpConfigPath,
                    "--output-format", "stream-json",
                    "--allowedTools", "mcp__stock__*,mcp__brave-search__*"
                ).redirectErrorStream(true).start()
 
                process.inputStream.bufferedReader().lines().forEach { line ->
                    parseStreamJson(line)?.let { emitter.send(it) }
                }
                process.waitFor()
                emitter.complete()
                history.add("User: ${request.message}")
            } catch (e: Exception) {
                emitter.completeWithError(e)
            }
        }.start()
 
        return emitter
    }
}

Component Diagram

flowchart LR
    subgraph FE["Frontend"]
        ChatUI[챗봇 UI]
    end
    subgraph BE["Backend (Spring Boot :8080)"]
        subgraph Aggregator["Aggregator (presentation)"]
            ChatCtrl[ChatApiController]
        end
        subgraph McpServer["MCP Server (presentation)"]
            Stock[StockMcpTools]
            Watch[WatchlistMcpTools]
            Alert[AlertMcpTools]
            Order[OrderMcpTools]
        end
        subgraph Application
            UC[기존 UseCases]
        end
    end
    subgraph Local["로컬 CLI"]
        Claude[claude -p]
    end
    subgraph External
        Brave[Brave MCP Server]
        TossAPI[Toss Open API]
    end
    ChatUI -->|POST /api/chat/message| ChatCtrl
    ChatCtrl -->|subprocess| Claude
    Claude -->|MCP SSE tool call| Stock
    Claude -->|MCP SSE tool call| Watch
    Claude -->|MCP SSE tool call| Alert
    Claude -->|MCP SSE tool call| Order
    Claude -->|MCP stdio| Brave
    Stock --> UC
    Watch --> UC
    Alert --> UC
    Order --> UC
    UC --> TossAPI

Sequence Diagram

sequenceDiagram
    participant U as User
    participant FE as Chat UI
    participant API as ChatApiController
    participant CLI as claude -p
    participant MCP as Spring MCP Server
    participant Brave as Brave MCP

    U->>FE: "삼성전자 최신 뉴스와 현재가 알려줘"
    FE->>API: POST /api/chat/message
    API->>CLI: subprocess: claude -p "{prompt}" --mcp-config stock-mcp.json
    CLI->>Brave: brave_web_search("삼성전자 뉴스")
    Brave-->>CLI: 뉴스 기사 목록
    CLI->>MCP: searchStocks("005930") via SSE
    MCP-->>CLI: StockResponse
    CLI-->>API: stream-json 청크 (스트리밍)
    API-->>FE: SSE 스트리밍
    FE-->>U: 실시간 응답 표시

Tool 정의 상세

StockMcpTools

@Component
class StockMcpTools(
    private val listStocksUseCase: ListStocksUseCase,
    private val searchStocksUseCase: SearchStocksUseCase,
    private val getStockPricesUseCase: GetStockPricesUseCase,
) {
    @Tool(description = "주식 종목 목록 조회. 종목명·코드·현재가·등락률 포함.")
    fun listStocks(page: Int, size: Int): List<StockResponse>
 
    @Tool(description = "종목명 또는 종목 코드로 종목 검색.")
    fun searchStocks(query: String): List<StockResponse>
 
    @Tool(description = "종목 코드 목록의 현재 시세를 일괄 조회.")
    fun getStockPrices(stockCodes: List<String>): List<StockPriceResponse>
}

빌드 의존성 추가 (build.gradle.kts)

// BOM
implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0"))
 
// MCP Server (SSE 트랜스포트) — Anthropic/Tavily 스타터 불필요
implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")

설정 (application.yml 추가)

spring:
  ai:
    mcp:
      server:
        name: stock-mcp-server
        version: 1.0.0
        type: SYNC
 
chat:
  mcp-config-path: ${MCP_CONFIG_PATH:/etc/stock-mcp.json}
  claude-path: ${CLAUDE_PATH:claude}

MCP_CONFIG_PATH 환경 변수로 stock-mcp.json 위치를 외부 주입한다.

ERD

변경 없음. 대화 히스토리는 인메모리(ConcurrentHashMap)로 관리하며 DB 영속화 불필요.

Testing Plan

  • StockMcpTools.searchStocks("삼성전자") 호출 시 SearchStocksUseCase에 위임한다.
  • ChatApiController에 메시지 전송 시 claude -p를 올바른 인자로 subprocess 실행한다.
  • claude -p 종료 코드가 0이 아닐 때 SSE에 error 이벤트를 전송한다.
  • 동일 sessionId로 2회 요청 시 이전 대화가 프롬프트에 포함된다.
  • /mcp/sse 엔드포인트가 200 응답을 반환한다.

Release Scenario

  1. stock-mcp.json 파일 작성 후 서버에 배치.
  2. MCP_CONFIG_PATH 환경 변수 설정.
  3. build.gradle.kts 의존성 추가 후 빌드.
  4. 백엔드 재시작 — /mcp/sse 자동 활성화.
  5. 로컬 머신에 claude CLI 설치 확인 (claude --version).
  6. FE 챗봇 컴포넌트 배포.
  7. 주문 구현 완료 후 OrderMcpTools @ConditionalOnProperty 제거.

롤백: spring.ai.mcp.server.enabled=false로 MCP 전체 비활성화. 기존 REST API에 영향 없음.

Observability

지표측정 방법
Tool 호출 횟수·지연Spring AI Actuator /actuator/ai
claude -p 응답 시간ChatApiController 내 System.nanoTime() 로그
subprocess 오류율process.exitValue() != 0 카운터 로그
MCP SSE 연결 수Spring MVC 액추에이터 지표

Project Information

  • 담당자: biuea
  • 전제: 로컬 머신에 claude CLI 설치 완료
  • 예상 소요: 3~4일

Document History

날짜변경 내용작성자
2026-06-19최초 작성biuea
2026-06-19claude -p 애그리게이터 패턴으로 변경 (Anthropic API 제거)biuea
2026-06-21스킬 포맷 적용biuea