첫 번째 MCP 서버를 Java와 Spring Boot로 작성하기
1. 개요
Model Context Protocol (MCP)는 AI assistant(Claude, Copilot 등)가 도구를 호출하고 당신의 시스템에서 리소스를 읽을 수 있도록 하는 표준 통신 규약입니다. 이 글은 Java 21로 MCP 서버를 작성하고, 주문 조회 도구(lookup_order)를 expose하며, stdio transport를 통해 MCP 클라이언트와 연결을 시도하는 방법을 안내합니다. 이 글을 읽은 후에는 실제로 작동하는 MCP 서버를 구축하고, 도구 handler에 대한 테스트를 작성하며, 처음 시작할 때 흔히 마주치는 오류들을 알게 됩니다.
2. MCP란 무엇이며 서버를 작성해야 하는 경우
MCP는 서버가 expose할 수 있는 세 가지 주요 개념을 정의합니다:
- Tool: AI 모델이 호출할 수 있는 함수로, 명확한 입력 schema를 가집니다(function calling과 비슷합니다).
- Resource: 클라이언트가 읽을 수 있는 정적 또는 동적 데이터(파일, 설정, 데이터베이스 레코드 등).
- Prompt: 클라이언트가 재사용할 수 있도록 미리 구성된 prompt 템플릿.
AI assistant가 당신의 내부 시스템을 직접 조작하고 싶을 때 MCP 서버를 작성해야 합니다. 예를 들어 주문 조회, 티켓 생성, 로그 쿼리 등을 자동으로 처리하면서 수동으로 데이터를 채팅에 복사-붙여넣기할 필요가 없습니다.
참고: 정적 텍스트 기반 응답만 필요하다면 RAG나 일반적인 prompt를 사용하는 것이 전체 MCP 서버를 구축하는 것보다 훨씬 간단합니다.
3. 프로젝트 준비
3.1. Maven Dependency
MCP Java SDK는 서버와 transport를 구축하기 위한 클래스를 제공합니다. 다음 dependency를 pom.xml에 추가하세요:
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.8.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Jackson은 MCP가 내부적으로 사용하는 JSON-RPC 메시지를 직렬화/역직렬화하는 데 사용됩니다.
3.2. 간단한 Domain 모델
Tool을 작성하기 전에, tool이 반환할 데이터가 필요합니다. 가짜 Order를 사용하겠습니다:
public record Order(String id, String status, double total) {
}
@Service
public class OrderService {
private final Map<String, Order> orders = Map.of(
"ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
"ORD-002", new Order("ORD-002", "PENDING", 120_000)
);
public Optional<Order> findById(String orderId) {
return Optional.ofNullable(orders.get(orderId));
}
}
OrderService는 데모용 메모리 내 맵일 뿐입니다. 실제 프로젝트에서는 repository나 API를 호출할 것입니다.
4. 첫 번째 도구 정의: lookup_order
MCP의 각 tool은 이름, 설명(모델이 tool을 호출할지 결정할 때 사용), 그리고 JSON Schema 형식의 입력 schema이 필요합니다.
public final class OrderTools {
public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
"lookup_order",
"주문 ID를 기준으로 주문의 상태와 총액을 조회합니다",
"""
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "주문 ID (예: ORD-001)"
}
},
"required": ["orderId"]
}
"""
);
}
"lookup_order"는 tool의 이름이며, 클라이언트가 이 도구를 호출할 때 정확히 이 문자열을 사용합니다.- 두 번째 설명 문장이 매우 중요합니다: 모델이 이를 기반으로 다른 도구 대신 이 도구를 언제 호출해야 하는지 결정합니다.
- Schema는
orderId를 필수 필드로, 타입은string으로 지정합니다.
다음으로, tool을 실행하는 handler를 작성합니다. 매개변수를 받아 OrderService를 호출하고 CallToolResult를 반환합니다:
@Component
public class OrderToolHandler {
private final OrderService orderService;
public OrderToolHandler(OrderService orderService) {
this.orderService = orderService;
}
public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
String orderId = (String) arguments.get("orderId");
return orderService.findById(orderId)
.map(order -> success(order))
.orElseGet(() -> error("주문을 찾을 수 없습니다: " + orderId));
}
private McpSchema.CallToolResult success(Order order) {
String text = "주문 %s: 상태 %s, 총액 %.0f원"
.formatted(order.id(), order.status(), order.total());
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
}
private McpSchema.CallToolResult error(String message) {
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
}
}
CallToolResult의 두 번째 매개변수는 isError입니다. true일 때, 클라이언트는 tool이 실패했음을 알고 모델에 보고하여 다른 방법을 시도하도록 할 수 있으며, 유효한 결과로 간주하지 않습니다.
5. stdio transport를 사용한 MCP 서버 초기화
MCP 서버 작성을 처음 시작할 때 가장 일반적인 transport는 stdio입니다: 서버는 stdin에서 요청을 읽고 stdout으로 응답을 씁니다. 이것이 Claude Desktop이 로컬 서버를 시작하는 방식이기도 합니다.
@Component
public class McpOrderServer implements CommandLineRunner {
private final OrderToolHandler toolHandler;
public McpOrderServer(OrderToolHandler toolHandler) {
this.toolHandler = toolHandler;
}
@Override
public void run(String... args) {
McpServerTransportProvider transportProvider =
new StdioServerTransportProvider(new ObjectMapper());
McpServerFeatures.SyncToolSpecification lookupOrderSpec =
new McpServerFeatures.SyncToolSpecification(
OrderTools.LOOKUP_ORDER,
(exchange, arguments) -> toolHandler.handle(arguments)
);
McpSyncServer server = McpServer.sync(transportProvider)
.serverInfo("nonla-order-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(true)
.build())
.tools(lookupOrderSpec)
.build();
server.start();
}
}
StdioServerTransportProvider는 process의stdin/stdout을 JSON-RPC 통신 채널로 변환합니다.serverInfo(...)는 이름과 버전을 선언합니다. 클라이언트는 연결된 서버를 나열할 때 이 정보를 표시합니다.capabilities().tools(true)는 클라이언트에게 서버가 tool을 expose한다고 알립니다. 클라이언트는 목록을 얻기 위해tools/list를 보냅니다.server.start()는 프로세스를 블록하고 stdin을 통한 클라이언트 요청을 기다립니다.
참고: stdio transport가 프로세스를 블록하여 유지하기 때문에, 일반적인 Spring Boot 웹 앱(웹 서버가 있는
@SpringBootApplication)에서 실행 중이라면 포트 충돌을 피하고 stdout에 로그가 섞이는 것을 방지하기 위해 웹 서버를 비활성화해야 합니다(application.properties에서spring.main.web-application-type=none).
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=
Banner와 콘솔 로그는 완전히 비활성화해야 합니다. MCP는 stdout을 데이터 전송 채널로 사용하므로, 여기에 섞이는 모든 로그 줄은 JSON-RPC 메시지를 손상시킵니다.
6. Resource 추가: 정적 데이터 Expose
Tool 외에도, 클라이언트가 직접 읽을 수 있도록 resource를 expose할 수 있습니다(model을 통해 호출할 필요 없음):
McpSchema.Resource policyResource = new McpSchema.Resource(
"file:///policies/return-policy.md",
"반품 정책",
"상품 반품 정책 내용",
"text/markdown",
null
);
McpServerFeatures.SyncResourceSpecification policySpec =
new McpServerFeatures.SyncResourceSpecification(
policyResource,
(exchange, request) -> new McpSchema.ReadResourceResult(
List.of(new McpSchema.TextResourceContents(
policyResource.uri(),
"text/markdown",
"# 반품 정책\n고객은 7일 이내에 환불을 받을 수 있습니다."
))
)
);
5단계의 builder에 .resources(policySpec)을 추가하면 서버가 이 리소스를 추가로 expose합니다.
7. Claude Desktop으로 테스트 실행
jar를 빌드한 후 Claude Desktop(또는 다른 MCP 클라이언트)이 당신의 서버를 가리키도록 구성합니다:
{
"mcpServers": {
"nonla-order-server": {
"command": "java",
"args": ["-jar", "/path/to/order-mcp-server.jar"]
}
}
}
클라이언트를 다시 시작하고, 새로운 대화를 열어 다음과 같이 물어봅니다: “주문 ORD-001은 어떤 상태인가요?”. 모델은 자동으로 orderId = "ORD-001"을 사용하여 lookup_order를 호출하기로 결정하고, 서버는 텍스트를 반환하며, 모델이 그 결과를 사용하여 당신에게 답변합니다.
8. Tool Handler에 대한 테스트 작성
가장 중요한 논리 부분—매개변수 처리 및 CallToolResult 반환—은 실제 transport가 없어도 일반 Java 코드처럼 완전히 테스트할 수 있습니다.
class OrderToolHandlerTest {
private final OrderService orderService = new OrderService();
private final OrderToolHandler handler = new OrderToolHandler(orderService);
@Test
void shouldReturnOrderInfo_whenOrderExists() {
var result = handler.handle(Map.of("orderId", "ORD-001"));
assertThat(result.isError()).isFalse();
assertThat(result.content())
.hasSize(1)
.first()
.isInstanceOfSatisfying(McpSchema.TextContent.class,
content -> assertThat(content.text()).contains("SHIPPED"));
}
@Test
void shouldReturnError_whenOrderNotFound() {
var result = handler.handle(Map.of("orderId", "ORD-999"));
assertThat(result.isError()).isTrue();
}
}
첫 번째 테스트는 유효한 주문을 확인합니다: isError()는 false여야 하고 텍스트 내용에는 SHIPPED 상태가 포함되어야 합니다. 두 번째 테스트는 주문을 찾지 못했을 때 isError()가 true를 반환하는지 확인합니다. 이것은 클라이언트/모델이 다르게 처리해야 함을 알 수 있는 중요한 신호입니다.
✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound
9. 자주 마주치는 오류
- stdout에 로그가 섞임: stdio transport를 사용하면서 banner/로그 콘솔을 비활성화하는 것을 잊으면 클라이언트가 손상된 JSON-RPC 메시지를 받게 되며, parse 오류는 매우 디버깅하기 어렵습니다. 수동 테스트 시
stdout이 file로 redirect되어 MCP 메시지만 포함되는지 항상 확인하세요. - Tool 설명이 너무 간단함: 설명이 단순히 “조회 주문”만 작성되어 있으면 모델은 언제 이를 호출해야 하는지 종종 알 수 없습니다. 예상되는 입력/출력을 명확히 설명하세요.
- Input schema에
required누락: 모델이 필수 필드 없이 tool을 호출할 수 있으며, 매개변수를 캐스팅할 때 handler가NullPointerException에 직면할 수 있습니다. handler에서 항상 입력 매개변수를 검증하고 schema를 완전히 신뢰하지 마세요. - Async 서버(
McpServer.async)를 사용하지만 올바르게 await하지 않음: 여러 tool 요청이 거의 동시에 도착할 때 race condition이 발생하기 쉽습니다. reactive에 아직 익숙하지 않다면, 이 글에서처럼McpServer.sync로 시작하세요.
MCP 서버를 별도로 작성하면 안 되는 경우: 시스템에 이미 안정적인 REST API가 있고 하나의 클라이언트만 직접 호출하면 되는 경우, MCP 레이어를 추가로 작성하는 것은 중복입니다. MCP는 여러 AI 클라이언트(Claude Desktop, IDE 플러그인, 자체 작성 agent)가 각각 통합하지 않고도 동일한 도구를 사용할 수 있도록 하려는 경우에만 투자할 가치가 있습니다.
10. 결론
당신은 완전한 MCP Java 서버를 방금 구축했습니다: 명확한 schema를 가진 tool을 선언하고, 논리를 처리하는 handler를 작성하고, 리소스를 추가로 expose하고, transport와 독립적으로 handler를 테스트했습니다. 다음 단계는 자연스럽게 SSE transport를 시도하여 stdio 대신 HTTP를 통해 서버를 실행하거나, 당신의 domain에서 일반적인 반복 작업을 위해 prompt 템플릿을 추가하는 것입니다.