지난 1년 사이 “내 데이터를 AI에게 안전하게 연결하는 표준”으로 자리 잡은 것이 MCP(Model Context Protocol)다. 개념과 생태계는 앞서 MCP 프로토콜 입문에서 다뤘다면, 이번 글의 주제는 그 다음 단계 — MCP 서버 만들기다. 즉 Claude Code나 Cursor 같은 클라이언트가 호출할 수 있는 도구·데이터 서버를 개발자가 직접 구현하는 과정이다.
좋은 소식은, MCP 서버 만들기가 생각보다 어렵지 않다는 점이다. 공식 SDK는 함수 하나에 데코레이터를 붙이는 것만으로 도구가 등록되도록 설계돼 있다. 이 글에서는 Anthropic 공식 문서와 SDK 저장소를 1차 출처로 삼아, Python과 TypeScript 두 갈래로 최소 서버를 만들고 Claude에 연결하는 전 과정을, 자주 막히는 함정까지 함께 정리한다.
MCP 서버가 하는 일 — 세 가지 프리미티브 복습
코드를 보기 전에 MCP 서버가 무엇을 노출하는지부터 짚어야 한다. MCP 공식 사양(현행 버전 2025-06-18)은 서버가 클라이언트에 제공하는 기능을 세 가지 프리미티브(primitive) 로 정의한다.
| 프리미티브 | 정의 | 통제 주체 | 비유 |
|---|---|---|---|
| Tools | 모델이 호출해 동작·실시간 조회를 수행하는 함수 | 모델(사용자 승인 후) | API 엔드포인트 |
| Resources | 클라이언트가 읽어 컨텍스트로 넣는 파일·데이터 | 사용자/앱 | 파일 시스템 |
| Prompts | 재사용 가능한 프롬프트 템플릿·워크플로우 | 사용자 | 슬래시 명령 |
핵심 차이는 “누가 그 기능을 켜는가”다. 공식 사양에 따르면 Tools는 모델이 직접 호출하되 실행 전 사용자 승인을 거치고, Resources는 보통 사용자가 선택해 모델 컨텍스트에 주입하며, Prompts는 사용자가 슬래시 명령처럼 끌어다 쓰는 템플릿이다. 처음 서버를 만들 때는 대부분 Tools 하나로 시작하므로, 이 글의 실습도 Tools를 중심에 둔다.
세 프리미티브의 동작 원리와 200여 개 서버 생태계는 MCP 프로토콜 입문 글에서 더 깊게 다뤘다. 이 글은 “직접 구현” 각도이므로 개념 설명은 최소화한다.
시작 전 준비물
공식 Python 튜토리얼이 요구하는 환경은 단출하다.
- Python 3.10 이상
- MCP Python SDK 1.2.0 이상
- 패키지·가상환경 관리 도구
uv(권장) 또는pip
uv는 Astral이 만든 빠른 파이썬 패키지 매니저로, 공식 문서가 표준 도구로 채택했다. 설치는 한 줄이다.
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
설치 후 터미널을 한 번 재시작해야 uv 명령이 인식된다. 사소해 보이지만, 첫 실습에서 “command not found”로 막히는 흔한 지점이다.
Python으로 MCP 서버 만들기
가장 빠른 길은 공식 SDK에 포함된 FastMCP 클래스다. 타입 힌트와 docstring을 읽어 도구 정의를 자동 생성해 주기 때문에, 보일러플레이트 없이 함수만 작성하면 된다. 공식 문서의 날씨 서버 예제를 그대로 따라가 보자.
1) 프로젝트 생성과 의존성 설치
# 프로젝트 디렉터리 생성
uv init weather
cd weather
# 가상환경 생성 및 활성화
uv venv
source .venv/bin/activate # Windows: .venvScriptsactivate
# 의존성 설치
uv add "mcp[cli]" httpx
# 서버 파일 생성
touch weather.py # Windows: new-item weather.py
mcp[cli]는 MCP SDK 본체에 개발용 CLI까지 포함한 설치 옵션이다. httpx는 예제에서 외부 날씨 API를 호출하려고 추가한 비동기 HTTP 클라이언트일 뿐, MCP 자체 의존성은 아니다.
2) 서버 인스턴스 만들기
weather.py 상단에 다음을 둔다.
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
# FastMCP 서버 초기화
mcp = FastMCP("weather")
NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"
FastMCP("weather")의 문자열은 서버 이름이다. 이 한 줄이 이후 모든 도구가 등록될 컨테이너가 된다.
3) 도구(Tool) 등록 — 데코레이터 한 줄
MCP 서버 만들기의 핵심이자 가장 간단한 부분이다. 일반 함수에 @mcp.tool()만 붙이면 그 함수가 도구가 된다. FastMCP가 함수 시그니처의 타입 힌트로 입력 스키마를, docstring으로 도구 설명을 자동 생성한다.
@mcp.tool()
async def get_alerts(state: str) -> str:
"""미국 주(state)의 기상 경보를 조회한다.
Args:
state: 두 글자 주 코드 (예: CA, NY)
"""
url = f"{NWS_API_BASE}/alerts/active/area/{state}"
data = await make_nws_request(url)
if not data or "features" not in data:
return "경보를 가져올 수 없거나 경보가 없습니다."
if not data["features"]:
return "해당 주에 활성 경보가 없습니다."
alerts = [format_alert(f) for f in data["features"]]
return "n---n".join(alerts)
여기서 주목할 점은, 별도의 JSON Schema를 손으로 쓰지 않았다는 것이다. state: str이라는 타입 힌트가 곧 입력 스키마가 되고, docstring이 모델에게 “이 도구가 무엇을 하는지” 알려 주는 설명이 된다. 모델은 이 메타데이터만 보고 올바른 호출을 구성한다.
4) 서버 실행
마지막으로 서버를 띄운다.
def main():
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
transport="stdio"는 표준 입출력으로 클라이언트와 JSON-RPC 메시지를 주고받겠다는 의미다. 로컬에서 Claude Desktop 같은 데스크톱 클라이언트에 연결할 때 가장 흔히 쓰는 방식이다. uv run weather.py로 실행하면 서버가 호스트의 메시지를 기다린다.
Resources와 Prompts도 같은 패턴
도구만이 아니다. 자원과 프롬프트도 데코레이터 한 줄로 등록한다. SDK 저장소의 최소 예제를 보면 패턴이 일관적임을 알 수 있다.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 수를 더한다"""
return a + b
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
"""개인화된 인사말을 반환한다"""
return f"Hello, {name}!"
@mcp.resource("greeting://{name}")처럼 URI 템플릿을 지정하면, 클라이언트는 greeting://Claude 같은 주소로 해당 자원을 읽을 수 있다. 같은 함수, 다른 데코레이터 — 이 단순함이 FastMCP가 “가장 빠른 길”로 불리는 이유다.
TypeScript로 MCP 서버 만들기
Python이 주력이 아니라면 TypeScript SDK도 1급 시민이다. 공식 패키지는 @modelcontextprotocol/sdk이며, 입력 검증에 zod를 함께 쓴다.
mkdir weather && cd weather
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript
package.json에 "type": "module"을 넣고, src/index.ts 상단을 다음처럼 구성한다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// 서버 인스턴스 생성
const server = new McpServer({
name: "weather",
version: "1.0.0",
});
구조는 Python과 대칭이다. McpServer로 인스턴스를 만들고, 도구를 등록한 뒤, StdioServerTransport로 연결한다. Python의 타입 힌트가 하던 역할을 TypeScript에서는 zod 스키마가 대신한다. 두 언어 중 무엇을 고르든 “서버 생성 → 도구 등록 → 트랜스포트 연결”이라는 3단 구조는 동일하다.
만든 서버를 Claude·Cursor에 연결하기
서버를 실행하는 것만으로는 아무 일도 일어나지 않는다. 클라이언트가 그 서버를 알아야 한다. Claude Desktop의 경우 설정 파일 claude_desktop_config.json의 mcpServers 키에 서버를 등록한다.
{
"mcpServers": {
"weather": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather",
"run",
"weather.py"
]
}
}
}
이 설정은 Claude Desktop에게 “weather라는 MCP 서버가 있고, uv --directory ... run weather.py 명령으로 실행하라”고 알려 준다. 파일을 저장하고 클라이언트를 재시작하면 도구가 나타난다. 같은 mcpServers 형식을 Cursor·Claude Code 등 다른 클라이언트도 거의 그대로 사용하므로, 한 번 익히면 재활용된다. 여러 도구를 한 번에 다루는 워크플로우는 AI 코딩 에이전트 비교 글의 도구별 차이와 함께 보면 그림이 더 선명해진다.
설정에서 자주 어긋나는 두 가지를 미리 짚자. 첫째, 반드시 절대 경로를 써야 한다. 상대 경로를 넣으면 클라이언트가 서버를 찾지 못한다. 둘째, Windows에서는 JSON 경로에 역슬래시를 두 번(\) 쓰거나 슬래시(/)로 바꿔야 한다. uv 실행 파일을 못 찾는다면 which uv(Windows는 where uv)로 전체 경로를 확인해 command에 직접 넣는다.
가장 많이 막히는 함정 — STDIO 로깅
직접 만들다 보면 “서버는 켜졌는데 클라이언트가 인식을 못 한다”는 상황을 만난다. 원인 1순위는 표준 출력(stdout) 오염이다.
STDIO 트랜스포트에서는 stdout이 곧 JSON-RPC 통신 채널이다. 그런데 디버깅하겠다고 print("처리 중")(Python)이나 console.log("started")(TypeScript)를 호출하면, 그 문자열이 프로토콜 메시지 사이에 끼어들어 통신을 깨뜨린다. 공식 문서가 명시적으로 경고하는 지점이다.
# ❌ STDIO 서버에서 금지 — 통신 오염
print("Processing request")
# ✅ stderr로 보내거나 로깅 모듈 사용
import sys
print("Processing request", file=sys.stderr)
import logging
logging.info("Processing request")
TypeScript도 동일하게 console.log 대신 console.error(stderr로 출력)를 쓴다. 반대로 HTTP 기반 서버라면 stdout 로깅이 통신을 방해하지 않으므로 자유롭게 써도 된다. 트랜스포트가 무엇이냐에 따라 로깅 규칙이 갈린다는 점이 초심자가 가장 자주 놓치는 부분이다.
솔직한 한계 — 쉽다고 다 같은 난이도는 아니다
데코레이터 기반의 간결함은 분명한 장점이지만, 모든 MCP 서버 만들기가 15분짜리 실습으로 끝나지는 않는다. 현실적인 한계도 함께 봐야 한다.
- 로컬 STDIO ↔ 원격 HTTP의 격차: 내 컴퓨터에서 도는 stdio 서버를 팀이 공유하는 원격 서버로 올리려면 Streamable HTTP, TLS, OAuth 인증 같은 추가 설계가 붙는다. 단일 사용자 실습과 멀티 사용자 운영은 난이도가 다르다.
- SDK 버전 변동성: MCP는 빠르게 진화하는 표준이다. Python SDK README도 현재 안정판 v1.x를 문서화하면서 v2를 개발 중이라고 명시한다. 사양 역시
2025-06-18이 현행이지만 후속 릴리스 후보가 진행되고 있어, 오래된 블로그 예제를 그대로 따르면 import 경로나 API가 어긋날 수 있다. 1차 출처(공식 docs·SDK 저장소)를 기준으로 삼아야 하는 이유다. - 권한 설계의 책임: 도구가 파일을 지우거나 외부에 데이터를 보낼 수 있다면, 그 위험은 서버를 만든 쪽의 책임이다. “모델이 호출 전 사용자에게 확인을 구한다”는 보호 장치가 있지만, 애초에 위험한 동작을 노출하지 않는 설계가 우선이다.
이쯤에서 독자 입장의 질문을 던져 볼 만하다. 당신의 워크플로우에서 AI에게 정말로 연결하고 싶은 한 가지 데이터·동작은 무엇인가? 그 하나를 도구 함수 하나로 구현하는 것이, 거창한 서버를 설계하는 것보다 훨씬 빠른 첫걸음이다.
정리하면 — 작게 시작하는 MCP 서버 만들기
정리하면, MCP 서버 만들기의 본질은 “함수에 데코레이터를 붙여 AI가 호출할 수 있게 노출하는 일”이다. Python이라면 from mcp.server.fastmcp import FastMCP 한 줄과 @mcp.tool() 데코레이터, TypeScript라면 McpServer와 zod 스키마면 최소 서버가 완성된다. 그다음 claude_desktop_config.json에 등록해 클라이언트와 연결하면 끝이다.
가장 현실적인 출발점은 거대한 통합 서버가 아니라, 자신이 매일 쓰는 데이터 소스 하나를 도구 하나로 감싸는 것이다. 사내 위키 검색, 데이터베이스 조회, 사내 API 호출 — 무엇이든 단일 도구로 시작해 동작을 확인한 뒤 확장하는 편이 안전하다. STDIO 로깅 함정과 절대 경로 설정만 피하면, 첫 서버는 오늘 안에 Claude가 호출하게 만들 수 있다.
MCP 입문부터 직접 구현까지의 흐름이 궁금하다면 MCP 프로토콜 입문으로 개념을 다지고, 도구별 워크플로우 차이는 AI 코딩 에이전트 비교에서 이어 보길 권한다. 직접 만든 서버를 어떤 클라이언트에 붙일지 정하는 데 도움이 될 것이다.
