Spring AI로 MCP 서버 만드는 건 생각보다 간단합니다. 메서드에 @McpTool 하나 붙이면 JSON Schema까지 알아서 만들어 주니까요.
문제는 그 전에 있었습니다. Spring Boot 3.5에 올렸더니 서버가 아예 뜨질 않더군요. 표준 출력을 막아놓은 터라 로그도 안 나오고, 한참 헤매다 로그 파일을 열어보고 알았습니다. Jackson이었습니다.
이 이야기부터 하겠습니다. 여기서 막히면 아래는 볼 필요가 없으니까요.
Spring Boot 3.5에서는 뜨지 않습니다
spring-ai-starter-mcp-server 2.0.1을 Spring Boot 3.5.6에 올리고 실행하면 이렇게 죽습니다.
Caused by: java.lang.NoClassDefFoundError:
com/fasterxml/jackson/annotation/JsonSerializeAs
at tools.jackson.databind.introspect.JacksonAnnotationIntrospector.<clinit>
at org.springframework.ai.mcp.server.common.autoconfigure
.McpServerJsonMapperAutoConfiguration.mcpServerJsonMapper
tools.jackson 이라는 패키지 이름이 눈에 걸립니다. 의존성 트리를 열어보니 답이 나왔습니다.
spring-ai-starter-mcp-server:2.0.1
+- spring-ai-mcp:2.0.1
| \- io.modelcontextprotocol.sdk:mcp:2.0.0
| +- io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0
| \- io.modelcontextprotocol.sdk:mcp-core:2.0.0
\- spring-ai-mcp-annotations:2.0.1
MCP Java SDK 2.0이 Jackson 3을 씁니다. Spring Boot 3.x는 Jackson 2 계열을 관리하니 어긋날 수밖에 없습니다.
여기까지 알고 나서, 간단히 넘어갈 수 있을 줄 알았습니다. jackson-annotations 버전만 올려주면 되지 않을까 싶었거든요. 올려서 다시 띄웠더니 이번엔 다른 데서 걸립니다.
Caused by: java.lang.reflect.MalformedParameterizedTypeException:
Mismatch of count of formal and actual type arguments in constructor of
tools.jackson.core.TreeCodec: 0 formal argument(s) 1 actual argument(s)
at org.springframework.core.ResolvableType.forMethodParameter
at org.springframework.core.BridgeMethodResolver.findBridgedMethod
이번엔 Spring Framework 6의 타입 해석기가 Jackson 3 타입을 못 다룹니다. 라이브러리 하나 갈아끼워서 될 문제가 아니었습니다. 두 번 시도해 보고 포기했습니다.
결국 Spring Boot 4.0.8로 올렸고, 그러니 바로 떴습니다. Spring Boot 4는 Jackson 3과 Spring Framework 7을 기본으로 쓰거든요. 아래 내용은 전부 Spring Boot 4.0.8 / Spring AI 2.0.1 / JDK 21에서 돌려보고 쓴 것입니다.
Spring Boot 4로 올릴 때 무엇이 깨지는지는 Spring Boot 3.x → 4.0 마이그레이션 정리에 따로 적어두었습니다. 그 글에서도 Jackson 2 → 3 전환이 가장 손이 많이 가는 항목이었는데, 이번에 실제로 발목을 잡혔습니다.
설정
의존성은 하나로 끝납니다.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
</parent>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>
</dependencies>
스타터는 전송 방식에 따라 셋으로 나뉩니다. 로컬 프로세스로 띄울 거면 spring-ai-starter-mcp-server(stdio), 원격으로 열 거면 -webmvc나 -webflux를 쓰면 Streamable HTTP로 열립니다. 이 글은 확인이 제일 쉬운 stdio로 갑니다.
도구 만들기
메서드에 애너테이션만 붙이면 됩니다. 주문 조회 도구 세 개를 만들어 봤습니다.
@Service
public class OrderTools {
@McpTool(name = "get-order",
description = "주문 번호로 주문 상태와 결제 금액을 조회한다")
public Map<String, Object> getOrder(
@McpToolParam(description = "주문 번호. 예: ORD-1001", required = true)
String orderId) {
Map<String, Object> found = ORDERS.get(orderId);
if (found == null) {
throw new IllegalArgumentException("주문을 찾을 수 없습니다: " + orderId);
}
return found;
}
@McpTool(name = "list-orders", description = "주문 번호 목록을 반환한다")
public List<String> listOrders() {
return ORDERS.keySet().stream().sorted().toList();
}
@McpTool(name = "estimate-shipping",
description = "주문 금액과 지역으로 배송비를 계산한다")
public int estimateShipping(
@McpToolParam(description = "주문 금액(원)", required = true) int total,
@McpToolParam(description = "도서산간 여부", required = false) Boolean remote) {
int base = total >= 50_000 ? 0 : 3_000;
return Boolean.TRUE.equals(remote) ? base + 5_000 : base;
}
}
패키지는 org.springframework.ai.mcp.annotation 입니다. 같은 자리에 @McpResource, @McpPrompt, @McpArg도 있습니다. 빈으로만 등록되어 있으면 알아서 수집되니 별도 등록 코드는 필요 없습니다.
stdio에서는 표준 출력에 아무것도 흘리면 안 됩니다
앞에서 로그가 안 나와 헤맸다고 했는데, 그 이유가 여기 있습니다. stdio 전송에서는 표준 출력이 곧 JSON-RPC 메시지 통로입니다. 스프링 배너 한 줄만 섞여도 클라이언트가 JSON 파싱에 실패합니다.
배너 끄는 건 쉽습니다.
spring.main.web-application-type=none
spring.main.banner-mode=off
spring.ai.mcp.server.name=order-tools
spring.ai.mcp.server.version=1.0.0
문제는 로그였습니다. logging.pattern.console= 로 빈 값을 주면 되겠지 싶었는데, logback이 오히려 화를 내더군요.
ERROR in ch.qos.logback.classic.PatternLayout("") - Empty or null pattern.
콘솔 어펜더를 아예 두지 않는 쪽이 확실했습니다.
<!-- src/main/resources/logback-spring.xml -->
<configuration>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>mcp-server.log</file>
<encoder><pattern>%d{HH:mm:ss} %-5level %logger{20} - %msg%n</pattern></encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</configuration>
이렇게 해두면 디버깅이 오히려 편해집니다. 서버가 안 뜰 때 표준 출력에는 아무것도 없으니, 이 로그 파일이 유일한 단서가 됩니다. 저도 앞의 Jackson 오류를 이 파일에서 찾았습니다.
붙여서 확인하기
MCP는 JSON-RPC 2.0이라 클라이언트 없이도 표준 입출력으로 직접 붙을 수 있습니다. 줄바꿈으로 구분된 JSON을 한 줄씩 던지면 됩니다.
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"probe","version":"1.0"}}}' \
| java -jar target/mcp-server-1.0.jar
돌아온 응답입니다.
협상 protocolVersion: 2025-06-18
serverInfo: {'name': 'order-tools', 'version': '1.0.0'}
capabilities: ['completions', 'logging', 'prompts', 'resources', 'tools']
application.properties에 적은 이름과 버전이 그대로 serverInfo로 나갑니다. 도구만 만들었는데 prompts와 resources 능력까지 함께 선언되는 게 좀 의외였습니다. 실제로 제공하는 건 없는데도요.
이어서 tools/list를 던져봤습니다.
tools/list: 3개
- estimate-shipping 주문 금액과 지역으로 배송비를 계산한다
properties=['total', 'remote'] required=['total']
- get-order 주문 번호로 주문 상태와 결제 금액을 조회한다
properties=['orderId'] required=['orderId']
- list-orders 주문 번호 목록을 반환한다
properties=[] required=[]
메서드 시그니처만 보고 JSON Schema를 만들어냈습니다. estimate-shipping을 보면 required = false로 준 remote가 required 배열에서 정확히 빠져 있습니다. 애너테이션에 적은 대로 나오는군요.
이게 왜 중요하냐면, 모델이 이 스키마를 보고 인자를 만들기 때문입니다. description은 사람 읽으라고 쓰는 주석이 아니라 모델에게 주는 명세입니다. 대충 적으면 모델이 도구를 엉뚱하게 씁니다. 저 설명들을 쓸 때 “이걸 처음 보는 사람이 인자를 채울 수 있나”를 기준으로 삼으면 얼추 맞습니다.
호출도 잘 됩니다.
tools/call get-order(ORD-1001):
{
"content": [
{ "type": "text",
"text": "{\"status\":\"배송중\",\"id\":\"ORD-1001\",\"total\":34500}" }
],
"isError": false
}
Map을 반환했는데 JSON 문자열로 직렬화되어 content[].text에 담겼습니다. int를 반환한 estimate-shipping도 "8000" 이라는 문자열로 왔습니다.
예외를 던지면 어떻게 될까
여기가 제일 궁금했던 부분입니다. 없는 주문 번호를 넣어 IllegalArgumentException이 나게 해봤습니다.
{
"content": [
{ "type": "text", "text": "주문을 찾을 수 없습니다: NOPE" }
],
"isError": true
}
JSON-RPC의 error 필드가 아니라 정상 result 안에 isError: true로 옵니다. 예외 메시지가 그대로 실려 나가고요.
처음엔 이상하다고 생각했는데, 생각해 보니 이게 맞습니다. 도구 실행 실패는 프로토콜 오류가 아니라 모델이 읽고 판단해야 할 정보니까요. 프로토콜 오류로 던지면 클라이언트 라이브러리가 예외를 뱉고 대화가 끊깁니다. isError로 오면 모델이 메시지를 읽고 인자를 고쳐 다시 시도할 수 있습니다.
그래서 실무에서 지킬 게 두 가지 생깁니다.
예외 메시지를 모델이 읽는다고 생각하고 쓰세요. “주문을 찾을 수 없습니다: NOPE”처럼 뭐가 잘못됐는지 알 수 있어야 재시도가 됩니다. NullPointerException이 그대로 나가면 모델은 아무것도 못 합니다.
그리고 내부 정보가 새지 않게 조심해야 합니다. 스택 트레이스나 DB 오류 원문이 그대로 나가면 모델을 거쳐 사용자에게까지 노출됩니다.
한 가지 아쉬운 점도 있었습니다. 예외 메시지가 두 번 반복되어 담기는 경우가 있더군요. "주문을 찾을 수 없습니다: NOPE\n주문을 찾을 수 없습니다: NOPE" 이런 식으로요. Spring AI 2.0.1에서 본 동작이고, 메시지를 직접 구성해서 반환하면 피할 수 있습니다.
원격으로 열 생각이라면
stdio는 로컬 전용입니다. 원격으로 열려면 두 가지를 더 알아야 합니다.
먼저 전송 방식입니다. MCP의 원격 전송은 2024년 11월 사양의 HTTP+SSE에서 Streamable HTTP로 넘어갔고, 주요 클라이언트가 2026년 중반에 SSE 지원을 끊었습니다. -webmvc나 -webflux 스타터를 쓰면 Streamable HTTP로 열리니 그대로 가면 됩니다. 다만 검색해서 나오는 예제 중에 SSE 기준으로 쓰인 게 아직 많습니다. 그걸 따라 만들면 클라이언트가 안 붙습니다.
다음은 인가입니다. stdio에서는 프로세스 경계가 곧 신뢰 경계라 인가가 없어도 되지만, 원격은 다릅니다. MCP 인가 사양은 OAuth 2.1 기반이고 클라이언트에 PKCE(S256) 구현을 의무화합니다. 인가 서버 메타데이터에 code_challenge_methods_supported가 없으면 진행을 거부하라고까지 못 박아 놨습니다. MCP 서버는 OAuth 2.1의 자원 서버로 모델링됩니다.
왜 하필 OAuth 2.1인지, 에이전트 환경에서 혼동된 대리인 문제와 어떻게 엮이는지는 OAuth 2.1 변경점 정리에 적어두었습니다.
해보고 나서
코드 자체는 정말 간단합니다. @McpTool 붙인 빈 하나면 도구 목록도 스키마도 알아서 만들어집니다. 반나절이면 충분할 거라 생각했는데, 실제로 시간을 쓴 곳은 두 군데였습니다. Jackson 때문에 Spring Boot를 올려야 했던 것, 그리고 표준 출력에 로그가 섞여서 원인을 못 찾고 헤맨 것.
둘 다 코드가 아니라 환경 문제였습니다. Spring AI 문서만 보고 시작하면 이 둘을 만나기 전까지는 순조롭게 진행되는 것처럼 느껴집니다.
MCP 자체가 무엇이고 왜 나왔는지 궁금하다면 MCP란? AI와 데이터를 연결하는 차세대 표준에 정리해 두었습니다. 참조 서버에 직접 붙어서 확인한 프로토콜 메시지도 같이 실어 두었습니다.
여기 실린 코드와 실행 결과는 Spring Boot 4.0.8 / Spring AI 2.0.1 / MCP Java SDK 2.0.0 / JDK 21에서 직접 빌드하고 stdio로 접속해 확인한 것입니다(2026년 9월, 협상된 프로토콜 버전
2025-06-18). Spring AI와 MCP 사양 모두 개정이 빠른 편이니 시작 전에 최신 버전을 한 번 확인하세요.
참고 자료
- Spring AI Reference Documentation
- Model Context Protocol — Authorization
- spring-ai-bom 릴리스 목록 (Maven Central)