자바로 배우는 AI (4) — MCP
MCP(Model Context Protocol)는 대단한 게 아니다. SDK 없이 직접 구현해보면 드러난다. 전체 코드: github.com/dadaok/ai-playground
- MCP의 정체
- 왜 MCP인가 (JDBC 비유)
- SDK 없이 만든 MCP 서버
- 핸드셰이크 — 실제 wire 로그
- 동작 원리 — 서버는 내가 켜는 게 아니다
- “128 곱하기 7 해줘” 전체 흐름
- 연결하기
- 정리
MCP의 정체
- 전송(transport): stdio = 그냥 표준입출력 파이프 (또는 HTTP)
- 메시지: JSON-RPC 2.0 을 한 줄에 하나씩 (newline-delimited)
그게 전부다.
왜 MCP인가 (JDBC 비유)
3편까지는 calculator 도구를 프로그램 안에 박아넣었다. 재사용 불가. 이 도구를 MCP 서버로 빼두면 → Claude Desktop, Claude Code, 내 에이전트가 “똑같은 서버”를 각자 꽂아 쓴다. M개 앱 × N개 도구 통합이 M+N 으로 준다.
| 비유 | |
|---|---|
| 툴 | 전자제품의 기능 |
| MCP | USB 규격 자체 |
| MCP 서버 | USB 기기 (툴 1개 이상 담음) |
마우스가 USB 없이도(PS/2, 내장) 존재하듯, 툴은 MCP 없이도 존재한다. (2·3편이 그 예) MCP 서버 안엔 툴만 있고, 그걸 쓰는 머리(agent)는 바깥(호스트)에 있다.
SDK 없이 만든 MCP 서버
public class McpCalculatorServer {
private static final ObjectMapper M = new ObjectMapper();
public static void main(String[] args) throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, UTF_8));
String line;
while ((line = in.readLine()) != null) { // stdin 에 JSON 한 줄이 올 때까지 블로킹
if (line.isBlank()) continue;
handle(M.readTree(line));
}
}
private static void handle(JsonNode msg) {
String method = msg.path("method").asText(null);
JsonNode id = msg.get("id"); // 있으면 요청, 없으면 알림
switch (method) {
case "initialize" -> { /* 프로토콜 버전 + capabilities 응답 */ }
case "notifications/initialized" -> { /* 응답 없음 */ }
case "tools/list" -> reply(id, toolsList()); // 도구 목록 + JSON 스키마
case "tools/call" -> reply(id, callTool(msg.path("params"))); // 실제 실행
default -> { if (id != null) replyError(id, -32601, "지원하지 않는 method"); }
}
}
private static void send(JsonNode resp) throws Exception {
System.out.write((M.writeValueAsString(resp) + "\n").getBytes(UTF_8)); // stdout = 프로토콜 전용
System.out.flush();
}
private static void log(String s) { System.err.println("[mcp] " + s); } // 로그 = stderr
}
stdio 서버의 절대 규칙: stdout(System.out)에는 JSON-RPC만 쓴다.
System.out.println하나면 프로토콜이 깨진다. 로그·디버그는 전부 stderr로.
핸드셰이크 — 실제 wire 로그
호스트가 서버를 자식 프로세스로 띄운 뒤, 실제 도구 호출 전에:
호스트 → {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18", ...}}
서버 → {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{...}}}
호스트 → {"jsonrpc":"2.0","method":"notifications/initialized"} (알림, 응답 없음)
호스트 → {"jsonrpc":"2.0","id":2,"method":"tools/list"}
서버 → {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"calculator","inputSchema":{...}}]}}
호스트 → {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"calculator","arguments":{"op":"*","a":128,"b":7}}}
서버 → {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"896.0"}]}}
핸드셰이크 = 두 프로그램이 본격 대화 전에 프로토콜 버전·capabilities·서로의 정체를 맞추는 첫 인사. TCP 3-way handshake, TLS handshake, JDBC 커넥션 셋업과 같은 개념이다.
tools/list 가 돌려주는 스키마 = 2편에서 Tool.builder() 로 짰던 그 내용. 위치만 별도 프로세스로 옮긴 것이다.
동작 원리 — 서버는 내가 켜는 게 아니다
① .mcp.json 에 적어둠: "calc 도구가 필요하면 → java -jar mcp-calc-server.jar 를 실행해"
② 호스트(Claude Code)를 켜면 → 그 명령을 자식 프로세스로 실행, stdin/stdout 을 파이프로 붙잡음
③ 호스트를 끄면 → 파이프가 닫힘 → readLine() 이 null → while 루프 종료 → 프로세스 죽음
java -jar 는 테스트할 때만 직접 한다. 실제로는 호스트가 켜고 끈다.
“128 곱하기 7 해줘” 전체 흐름
당신 → Claude Code: "calculator로 128*7 해줘"
Claude Code → 모델: "쓸 수 있는 도구: [calculator (calc-java 서버 제공)]"
모델 → Claude Code: tool_use: calculator(op="*", a=128, b=7) ← 2편의 그 tool_use
Claude Code → calc-java 서버(파이프): {"method":"tools/call", ...}
calc-java 서버 → Claude Code(파이프): {"result":{"content":[{"text":"896.0"}]}}
Claude Code → 모델: tool_result: "896.0"
모델 → 당신: "128 곱하기 7은 896입니다"
MCP는 새 원리가 아니다. 2편의 에이전트 루프를 Claude Code가 대신 돌리고, calculator 도구만 별도 프로세스로 빠져 파이프로 연결됐을 뿐. MCP 서버는 모델과 대화하지 않는다. 그냥 도구 실행기다.
연결하기
.mcp.json (Claude Code) 또는 claude_desktop_config.json (Claude Desktop):
{
"mcpServers": {
"calc-java": {
"command": "java",
"args": ["-jar", "/절대경로/build/libs/mcp-calc-server.jar"]
}
}
}
정리
MCP = “도구/데이터 소스를 LLM 앱에 꽂는 표준 규격”. JDBC 드라이버처럼.
전송(stdio/HTTP)과 메시지(JSON-RPC)만 표준으로 맞추면 아무 호스트나 붙는다. MCP 서버가 줄 수 있는 것은 tools 말고도 resources(모델이 읽는 데이터), prompts(템플릿)가 있다. 다음 편은 harness — 에이전트 루프에 권한·컨텍스트 관리 같은 실전 장치를 두르는 것.