나의 첫 MCP 서버 구축하기: 스페인 기상 API와 2단계 캐치(two-step catch)
요약
백엔드 엔지니어가 Model Context Protocol(MCP) 서버를 구축하며 겪은 실전 경험을 공유합니다. 스페인 기상 API를 활용해 MCP 서버를 구축, npm 게시 및 레지스트리 등록까지의 전체 과정을 다룹니다.
핵심 포인트
- MCP는 AI 클라이언트가 외부 도구를 호출할 수 있게 하는 개방형 프로토콜임
- API 응답에서 데이터 URL을 받아 재요청하는 '2단계 캐치' 패턴 구현
- 복잡한 API 로직을 클라이언트 함수 내부로 격리하여 도구의 단순성 유지
- Node.js, TypeScript, MCP SDK, Zod를 활용한 서버 구축 방법
저는 AI 엔지니어링 쪽으로 전환하고 있는 백엔드 엔지니어(Java/Spring, Kubernetes 등의 분야)이며, AI 에이전트 생태계에 대해 읽기만 하는 대신 실제로 무언가를 출시해 보고 싶었습니다. 그래서 작은 Model Context Protocol (MCP) 서버를 구축하여 게시했습니다. 이것은 점진적으로 난이도가 높아질 계획된 시리즈의 첫 번째 작업입니다. 이번 작업은 의도적으로 범위가 사소했는데, 그 진짜 목표는 전체 루프를 완성하는 것이었기 때문입니다: 구축 → npm에 게시 → 공식 MCP 레지스트리에 등록 → 발견되기.
주제는 의도적으로 지루하게 선정했습니다. 스페인의 국립 기상청인 AEMET는 무료 공개 API를 제공합니다. API 키 외에 인증 문제도 없고, 법적 회색 지대도 없으며, 제 본업과 관련도 없는 내용입니다. 메커니즘을 배우기에 깨끗한 샌드박스(sandbox)입니다.
제가 예상하지 못했던 점은 이 "지루한" API가 이번 작업 전체에서 가장 흥미로운 두 가지 엔지니어링 교훈을 담고 있었다는 것입니다.
MCP란 무엇인가 (두 문장 요약)
MCP는 AI 클라이언트(Claude Desktop, IDE 에이전트 등)가 표준 인터페이스를 통해 외부 도구(tools)를 호출할 수 있게 해주는 개방형 프로토콜입니다. 당신은 몇 가지 타입이 지정된 "도구 (tools)"를 노출하는 서버를 작성하며, 그러면 모든 MCP 호환 클라이언트가 이를 발견하고 호출할 수 있습니다.
제가 만든 서버는 모두 읽기 전용인 세 가지 도구를 노출합니다:
get_municipality_forecast — 지방 자치체별(INE 코드) 예보 get_station_observation — 기상 관측소의 관측 데이터 get_weather_warnings — 지역별 활성 기상 경보
Node.js + TypeScript, 공식 @modelcontextprotocol/sdk, stdio 전송(transport), Zod를 사용한 입력값 검증을 사용했습니다. 특별한 것은 없습니다.
2단계 패턴 (흥미로운 부분)
AEMET의 OpenData API는 제가 이전에 본 적 없는 방식으로 동작하며, 처음 접하는 모든 사람을 당황하게 만듭니다. 첫 번째 호출은 데이터를 직접 반환하지 않고, 데이터에 대한 포인터(pointer)를 반환합니다.
예보를 요청하면:
`GET /opendata/api/prediccion/especifica/municipio/diaria/{ine_code}
Header: api_key: <key>`
그리고 다음과 같은 결과를 받게 됩니다:
json
`{
"descripcion": "exito",
"estado": 200,
...
온도 데이터가 단 하나도 없습니다. datos 필드는 AEMET가 실제로 응답을 배치해 둔 곳을 가리키는 URL입니다. 그런 다음 해당 URL로 두 번째 요청을 보내야 하며 — 이번에는 API 키가 필요 없습니다 — 그곳에 진짜 페이로드 (payload)가 들어 있습니다.
AWS를 알고 있다면, 이것은 S3 사전 서명된 URL (presigned URL) 패턴과 같습니다. 리소스를 요청하면 임시 링크를 받고, 그 링크에서 콘텐츠를 가져오는 방식입니다. API 엔드포인트 (endpoint)는 파일이 어디에 있는지 알려주는 인덱스 (index) 역할을 하며, 파일은 정적 저장소 (static storage)에서 제공됩니다. 또한, HTTP 클라이언트가 자동으로 따라가지 않기 때문에 수동으로 따라가야 하는 HTTP 302와도 약간 비슷합니다. 이는 Location 헤더가 아니라 JSON 바디 (body) 내의 필드이기 때문입니다.
설계 교훈: 이 과정을 한 곳에서 격리하십시오. 저는 두 번의 홉 (hop)을 하나의 클라이언트 함수 뒤로 배치하여, 도구(tools)들이 이러한 패턴의 존재를 전혀 알 수 없도록 했습니다:
async function fetchAemet<T>(path: string): Promise<T> {
// 1. api_key와 함께 엔드포인트 호출 → { estado, datos, metadatos }
// 2. estado 검증
...```
각 도구는 `fetchAemet(...)`를 호출하여 깨끗하고 타입이 지정된 (typed) 데이터를 받습니다. 만약 AEMET가 이 패턴을 변경하더라도, 저는 단 하나의 파일만 수정하면 됩니다.
**실제로 시간을 낭비하게 만든 함정들**
2단계 흐름은 문서화되어 있습니다 (거의 말이죠). 하지만 다음 사항들은 문서화되어 있지 않았습니다:
1. `estado` 필드는 HTTP 상태 코드와 다를 수 있습니다. JSON 본문은 "estado": 404 (해당 지방 자치 단체에 데이터 없음) 또는 401 (잘못된 키)을 나타내지만, 전송 계층(transport-level)에서는 200 OK가 반환될 수 있습니다. 따라서 응답 상태 코드뿐만 아니라 본문의 `estado`를 검증해야 합니다. "성공적인" 요청이 의미 없는 데이터를 반환할 때까지 놓치기 쉬운 부분입니다.
2. 인코딩 문제입니다. 이 문제 때문에 가장 많은 시간을 낭비했습니다. AEMET은 콘텐츠의 상당 부분을 UTF-8이 아닌 ISO-8859-1 (latin1)로 제공합니다. 만약 `await response.json()`을 순진하게 사용하면, 스페인어 악센트가 모두 손상되어 나타납니다. 예를 들어 Cádiz는 Cdiz가 되고, mañana는 maana가 됩니다. 본문을 버퍼(buffer)로 읽은 다음 명시적으로 디코딩해야 합니다. 당연히 알아야 할 문서 어디에도 경고하는 내용이 없으며, 그저 쓰레기 데이터만 받게 되어 왜 그런지 파악해야 합니다.
3. `datos` URL은 일시적입니다 (ephemeral). 몇 시간 동안 캐싱하지 마십시오. 재시도해야 한다면 처음부터 첫 번째 단계를 반복하십시오.
4. 속도 제한(Rate limiting)은 키당, 분당으로 적용됩니다. 몇 번의 도구 호출에는 문제가 없지만, 요청 체인을 너무 빠르게 수행하면 일부가 실패할 수 있으므로, 루프 안에서 재시도하기보다는 오류를 처리해야 합니다.
**다른 교훈: npm 서버는 패키징 단계에서 깨진다**
게시된 MCP 서버가 실패하는 가장 흔한 방법은 설치는 되지만 `npx`로 실행되지 않는 경우입니다. 이를 해결하는 두 가지 사항이 있으며, 둘 다 잊기 쉽습니다:
a bin 필드를 `package.json`에 포함하여 컴파일된 `dist/index.js`를 가리키게 하는 것
아무래도 엔트리 파일의 첫 줄에 shebang (#!/usr/bin/env node)을 추가하는 것
그리고 stdio 전송(stdio transport)과 관련된 런타임 트릭이 하나 더 있습니다: `stdout`으로는 JSON-RPC 프로토콜 외에는 아무것도 가지 않습니다. 실수로 넣은 `console.log`는 메시지 스트림을 손상시켜 서버를 조용히 망가뜨립니다. 로그는 `stderr`로 전송됩니다.
저는 외부에서 일반인이 설치하는 방식대로 게시된 패키지를 검증했습니다:
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
| AEMET_API_KEY=<key> npx -y @mmillan76/aemet-mcp
만약 serverInfo와 capabilities로 응답한다면, 살아있고 MCP를 사용하고 있다는 뜻입니다.
사용하기
AEMET의 OpenData 포털에서 무료 API 키를 발급받은 다음, 어떤 MCP 클라이언트에도 서버를 추가하세요:
json
{
"mcpServers": {
"aemet": {
...
npm: @mmillan76/aemet-mcp
MCP 디렉토리: mcp.so 등록이 검토 대기 중입니다 — 승인되는 대로 여기에 링크를 추가하겠습니다.
다음 단계
이것은 제가 더 큰 목표를 향해 구축하고 있는 시리즈의 첫 번째 단계였습니다: 바로 제가 직접 게시한 MCP 서버들 위에서 완전히 실행되는 자율 사고 조사 에이전트 (autonomous incident-investigation agent)를 만드는 것입니다. 다음 단계는 저의 실제 전문 분야인 읽기 전용 Kubernetes 진단(read-only Kubernetes diagnostics), 그리고 Helm 및 ArgoCD 툴링(tooling)으로 넘어가서, 그 위에서 해당 에이전트를 다시 구축할 예정입니다.
AEMET 서버 자체가 목적은 아니었습니다. 루프를 완성하는 것이 목적이었습니다. 만약 여러분이 첫 번째 MCP 서버 구축을 고민하고 있다면, 사소한 것을 골라 처음부터 끝까지 배포해 보세요. 그리고 패키징(packaging)과 인코딩(encoding)에 주의를 기울이세요. 진짜 교훈은 바로 그곳에 숨어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기