목차
- REST API 엔드포인트 vs MCP 도구 등록
- MCP 아키텍처 — 서버가 하는 일
- Python MCP 서버 만들기
- Claude Desktop에 연결하기
- 기존 FastAPI 프로젝트와 공존시키기
- Resources — 읽기 전용 데이터 노출
- HTTP Transport로 원격 접근 열기
- 새로 알게 된 것 — 메모
- 언제 MCP 서버를 만들고, 언제 안 만드는가
REST API 엔드포인트 vs MCP 도구 등록
REST API 엔드포인트 하나를 제대로 붙이려면 라우터 정의, 요청/응답 스키마, Swagger 문서 어노테이션까지 포함해서 체감상 30분 정도 걸린다. 같은 기능을 MCP 도구로 등록하는 건 함수 하나에 데코레이터 붙이면 끝이라 5분이면 된다. 프론트엔드에서 백엔드로 넘어온 지 2년 됐는데, API 설계할 때마다 "이걸 누가 어떻게 호출하지?"를 고민하는 시간이 제일 길었다. MCP는 그 고민을 LLM한테 넘긴다.
Model Context Protocol(MCP)은 Anthropic이 2024년 11월에 공개한 개방형 프로토콜이다. LLM이 외부 도구, 데이터 소스, 시스템과 표준화된 방식으로 통신하게 해준다. 쉽게 말하면 "AI 에이전트용 USB-C 포트" 같은 거다. 예전에는 LLM마다 Function Calling 포맷이 달라서 OpenAI용, Claude용 따로 만들어야 했는데, MCP 서버 하나 띄워두면 어떤 클라이언트든 같은 방식으로 연결된다.
오늘 기존 FastAPI 프로젝트의 내부 API 3개를 MCP 도구로 감싸봤다. 생각보다 간단했고, 몇 가지 삽질 포인트가 있었다. 그 과정을 정리한다.
MCP 아키텍처 — 서버가 하는 일
MCP의 구조는 클라이언트-서버 모델이다. Claude Desktop, Cursor, Cline 같은 앱이 MCP 클라이언트고, 우리가 만드는 게 MCP 서버다. 서버는 세 가지를 노출할 수 있다.
Tools, Resources, Prompts
Tools가 핵심이다. LLM이 "이 함수를 호출해야겠다"고 판단하면 MCP 클라이언트가 서버의 tool을 실행한다. get_weather("서울")처럼 입력을 받고 결과를 돌려주는 함수라고 보면 된다. Resources는 읽기 전용 데이터 소스(파일, DB 조회 결과 등)이고, Prompts는 재사용 가능한 프롬프트 템플릿이다.
프론트엔드 경험에 비유하면 이렇다. Tools는 onClick 핸들러, Resources는 useQuery로 가져오는 데이터, Prompts는 컴포넌트 props 기본값 정도의 역할이다. 실제로 서버를 만들 때 제일 많이 쓰는 건 Tools고, Resources와 Prompts는 상황에 따라 추가하는 정도다.
Transport 방식
서버와 클라이언트가 통신하는 방식은 두 가지다.
| 항목 | stdio | Streamable HTTP (SSE) |
|---|---|---|
| 연결 방식 | 표준 입출력 (stdin/stdout) | HTTP 기반 스트리밍 |
| 용도 | 로컬 실행 (Claude Desktop 등) | 원격 서버, 멀티 클라이언트 |
| 설정 난이도 | 낮음 | 중간 (CORS, 인증 등) |
| 디버깅 | 로그 파일 필요 | HTTP 요청으로 직접 확인 가능 |
처음 시작한다면 stdio부터 하는 게 맞다. Claude Desktop이 직접 Python 프로세스를 띄워서 stdin/stdout으로 통신하는 방식이라 네트워크 설정이 필요 없다. 나중에 팀원이나 외부에서 접근해야 할 때 HTTP transport로 전환하면 된다.
Python MCP 서버 만들기
패키지 설치
Anthropic이 공식으로 관리하는 Python SDK가 있다. mcp 패키지를 설치하면 된다.
# uv 사용 시 (권장)
uv add "mcp[cli]"
# pip 사용 시
pip install "mcp[cli]"
[cli] extra를 붙이면 mcp dev 같은 개발용 CLI 도구가 같이 설치된다. Python 3.10 이상이 필요하다. (출처: GitHub – modelcontextprotocol/python-sdk)
가장 단순한 서버
서버를 만드는 방법이 두 가지 있다. 고수준 API인 FastMCP와 저수준 Server 클래스. 대부분의 경우 FastMCP로 충분하다.
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("내부API") # 서버 이름
@mcp.tool()
def get_user_count() -> str:
"""현재 활성 사용자 수를 반환한다."""
# 실제로는 DB 쿼리
count = 1847
return f"현재 활성 사용자: {count}명"
@mcp.tool()
def search_logs(keyword: str, limit: int = 10) -> str:
"""서버 로그에서 키워드를 검색한다."""
# 실제로는 Elasticsearch 쿼리 등
return f"'{keyword}' 검색 결과: {limit}건 중 상위 항목..."
if __name__ == "__main__":
mcp.run() # stdio transport로 실행
이게 끝이다. @mcp.tool() 데코레이터를 함수에 붙이면 그 함수가 LLM이 호출할 수 있는 도구가 된다. 함수의 docstring이 도구 설명으로 쓰이고, 타입 힌트가 파라미터 스키마가 된다. FastAPI의 라우터 정의와 비슷한 느낌인데 더 간결하다.
여기서 중요한 건 반환 타입이 문자열이라는 점이다. LLM이 결과를 읽어야 하니까 JSON 딕셔너리를 반환해도 되지만, 최종적으로 LLM이 이해할 수 있는 텍스트로 변환되어야 한다. 복잡한 구조체를 반환하면 LLM이 파싱하느라 토큰을 낭비한다. 가능하면 사람이 읽기 좋은 형태로 포맷팅해서 돌려주는 게 좋다.
async 함수도 그대로 쓸 수 있다
기존에 FastAPI로 비동기 엔드포인트를 만들어뒀다면, 그 함수를 거의 그대로 가져다 쓸 수 있다.
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("내부API")
@mcp.tool()
async def get_deploy_status(service_name: str) -> str:
"""특정 서비스의 최근 배포 상태를 확인한다."""
async with httpx.AsyncClient() as client:
# 내부 CI/CD API 호출
resp = await client.get(
f"https://deploy.internal/api/v1/status/{service_name}",
timeout=10.0
)
data = resp.json()
return f"{service_name}: {data['status']} (버전 {data['version']}, {data['deployed_at']})"
async def로 정의하면 MCP 서버가 알아서 비동기로 처리한다. httpx나 aiohttp로 외부 API를 호출하는 것도 문제없다. FastAPI 프로젝트에서 서비스 레이어 함수를 import해서 그대로 감싸는 패턴이 실무에서 제일 편했다.
타입 힌트와 설명이 곧 스키마다
MCP 도구의 파라미터 스키마는 함수 시그니처에서 자동 생성된다. 여기서 삽질 포인트가 하나 있었다. 처음에 파라미터 설명 없이 타입만 적었더니 Claude가 도구를 호출할 때 파라미터 의미를 헷갈려 했다.
from pydantic import Field
@mcp.tool()
def query_database(
table: str = Field(description="조회할 테이블명 (users, orders, products)"),
conditions: str = Field(description="WHERE 절 조건. 예: age > 25 AND status = 'active'"),
limit: int = Field(default=50, description="최대 반환 행 수")
) -> str:
"""데이터베이스 테이블을 조건부 조회한다. SQL injection 방지를 위해 내부에서 파라미터화 처리한다."""
# 실제 구현...
return f"{table} 조회 결과: ..."
Field(description=...)으로 각 파라미터의 의미와 예시를 명시해주면 LLM이 정확하게 호출한다. docstring도 마찬가지다. "데이터베이스를 조회한다"보다 "데이터베이스 테이블을 조건부 조회한다. SQL injection 방지를 위해 내부에서 파라미터화 처리한다"가 훨씬 낫다. 이건 프론트에서 컴포넌트 props에 JSDoc 주석 다는 것과 같은 원리다. 소비자(LLM)가 잘 쓰게 하려면 설명이 풍부해야 한다.
Claude Desktop에 연결하기
서버를 만들었으면 클라이언트에 연결해야 한다. Claude Desktop 기준으로 설명한다.
설정 파일 수정
Claude Desktop의 MCP 설정 파일을 열어서 서버를 등록한다.
macOS 기준 경로: ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"내부API": {
"command": "uv",
"args": ["run", "--with", "mcp[cli]", "python", "/absolute/path/to/server.py"]
}
}
}
uv run을 쓰면 가상환경을 자동으로 잡아주니까 경로 문제가 줄어든다. python 대신 절대경로(/usr/local/bin/python3.12)를 쓰는 방법도 있는데, 가상환경 의존성이 있으면 uv run이 편하다.
설정 파일 저장 후 Claude Desktop을 재시작하면 채팅 입력창 옆에 도구 아이콘이 생긴다. 클릭하면 등록된 MCP 도구 목록이 보인다.
디버깅 — mcp dev
서버가 제대로 동작하는지 확인하려면 mcp dev 명령이 유용하다.
mcp dev server.py
브라우저에서 MCP Inspector가 열리면서 등록된 도구 목록, 파라미터 스키마, 직접 호출 테스트까지 할 수 있다. Claude Desktop에 연결하기 전에 여기서 먼저 확인하는 습관을 들이는 게 좋다. 나는 처음에 이걸 몰라서 Claude Desktop 로그 파일(~/Library/Logs/Claude/mcp*.log)을 하나하나 뒤졌다. mcp dev가 있다는 걸 알았을 때 좀 허탈했다.
연결이 안 될 때 가장 흔한 원인은 Python 경로 문제다. Claude Desktop이 시스템 PATH를 그대로 상속하지 않는 경우가 있어서, command에 Python 절대 경로를 명시하거나 uv run으로 감싸는 게 안전하다. ModuleNotFoundError: No module named 'mcp' 에러가 나오면 십중팔구 가상환경이 안 잡힌 거다.
기존 FastAPI 프로젝트와 공존시키기
새로 MCP 서버를 밑바닥부터 짜는 것보다, 기존 코드를 재활용하는 게 현실적이다. 내 경우 FastAPI 프로젝트의 서비스 레이어를 MCP 도구로 감쌌다.
프로젝트 구조는 대충 이렇게 됐다:
project/
├── app/
│ ├── main.py # FastAPI 앱
│ ├── services/
│ │ ├── user.py # 비즈니스 로직
│ │ └── deploy.py
│ └── ...
├── mcp_server.py # MCP 서버 (services 임포트)
└── pyproject.toml
mcp_server.py에서 app.services를 import하고, 각 서비스 함수를 @mcp.tool()로 감싸면 된다. 라우터 레이어(HTTP 요청/응답 처리)는 건너뛰고 서비스 레이어를 직접 호출하는 구조다. FastAPI의 Depends() 주입을 쓰고 있다면 그 부분만 풀어서 직접 넣어줘야 하는데, 대부분의 경우 DB 세션 정도라 크게 어렵지 않다.
이 구조의 장점은 REST API와 MCP 도구가 같은 비즈니스 로직을 공유한다는 거다. 버그 수정이나 로직 변경이 양쪽에 동시에 반영된다. 프론트엔드에서 UI 컴포넌트와 스토리북이 같은 컴포넌트를 참조하는 것과 비슷한 패턴이다.
한 가지 주의할 점이 있다. MCP 도구는 LLM이 호출하기 때문에 보안을 별도로 고려해야 한다. REST API에는 JWT 인증이 붙어 있어도 MCP 도구에는 인증이 빠져 있을 수 있다. stdio transport로 로컬에서만 쓸 때는 괜찮지만, HTTP transport로 외부에 노출하면 반드시 인증 레이어를 추가해야 한다.
Resources — 읽기 전용 데이터 노출
Tools만으로도 대부분의 작업이 가능하지만, Resources를 알아두면 쓸모가 있다.
@mcp.resource("config://app")
def get_app_config() -> str:
"""애플리케이션 설정 정보를 제공한다."""
return json.dumps({
"version": "2.4.1",
"environment": "production",
"feature_flags": {"new_dashboard": True}
}, ensure_ascii=False, indent=2)
Resource는 Tool과 달리 LLM이 "능동적으로 호출"하는 게 아니라, 클라이언트가 컨텍스트로 제공하는 데이터다. 설정 정보, 스키마 정의, 문서 같은 참조용 데이터에 적합하다. 아직 이 부분은 클라이언트 지원이 도구만큼 성숙하지 않아서, 실무에서는 Tool 위주로 쓰는 게 현실적이다.
HTTP Transport로 원격 접근 열기
로컬 stdio에서 벗어나 팀원들도 쓸 수 있게 하려면 HTTP transport가 필요하다. MCP 스펙이 2025년 3월에 Streamable HTTP로 업데이트되면서 기존 SSE 방식을 대체했다. (출처: MCP Specification – Transports)
# http_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("내부API")
# 도구 등록은 동일...
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
이렇게 실행하면 http://localhost:8000/mcp 엔드포인트가 생긴다. 원격 클라이언트에서 이 URL로 연결하면 된다.
여기서 CORS 문제를 만날 수 있다. 브라우저 기반 MCP 클라이언트를 쓰는 경우 Access-Control-Allow-Origin 헤더가 필요한데, FastMCP 생성 시 별도 설정이 필요할 수 있다. 나는 앞단에 nginx를 두고 CORS 헤더를 거기서 처리하는 방식으로 해결했다. MCP 서버 자체의 CORS 설정을 뒤지다가 시간을 좀 허비했는데, 인프라 레이어에서 처리하는 게 더 깔끔했다.
프로덕션에서 운영할 때는 인증도 추가해야 한다. MCP 스펙 자체에는 인증 메커니즘이 정의되어 있지만, 간단하게는 API 키를 헤더로 받아 미들웨어에서 검증하는 방식이 빠르다. 이 부분은 아직 직접 프로덕션에 올려본 건 아니라 자세한 내용은 생략한다.
새로 알게 된 것 — 메모
오늘 MCP 서버를 만들면서 몇 가지 기록해둘 만한 것들이 있다.
docstring이 곧 UX다. REST API에서는 Swagger UI가 개발자 경험을 결정했다면, MCP에서는 함수의 docstring과 파라미터 설명이 LLM의 도구 선택 정확도를 결정한다. "데이터 조회"보다 "users 테이블에서 조건에 맞는 사용자 목록을 반환한다. 최대 100건"이 LLM 입장에서 훨씬 명확하다. 처음에 대충 적었다가 Claude가 엉뚱한 도구를 호출하는 걸 보고 바로 고쳤다.
도구 개수는 적을수록 좋다. 20개 넘게 등록하면 LLM이 어떤 도구를 써야 할지 헷갈려한다. 비슷한 기능은 하나로 합치고, 파라미터로 분기하는 게 낫다. get_user_by_id, get_user_by_email, get_user_by_name을 따로 만들지 말고 search_user(query, search_type) 하나로 묶는 식이다.
에러 메시지를 LLM이 이해할 수 있게 돌려줘야 한다. Python traceback을 그대로 반환하면 LLM이 쓸데없이 긴 에러를 파싱하느라 토큰을 소모한다. try/except로 감싸서 "사용자를 찾을 수 없습니다. user_id를 확인해주세요" 같은 한 줄 메시지를 반환하는 게 맞다.
@mcp.tool()
async def get_user(user_id: int) -> str:
"""사용자 ID로 사용자 정보를 조회한다."""
try:
user = await user_service.get_by_id(user_id)
if not user:
return f"user_id={user_id}에 해당하는 사용자가 없다."
return f"이름: {user.name}, 이메일: {user.email}, 가입일: {user.created_at}"
except Exception as e:
return f"조회 실패: {str(e)}" # traceback 대신 요약 메시지
stdio에서는 print()를 쓰면 안 된다. stdout이 MCP 프로토콜 통신에 사용되기 때문에 print()로 디버그 로그를 찍으면 프로토콜이 깨진다. logging 모듈로 stderr에 출력하거나 파일에 로그를 남겨야 한다. 이건 공식 문서에 나오는 내용인데 처음에 못 보고 "왜 연결이 끊기지?" 하면서 한참 헤맸다.
언제 MCP 서버를 만들고, 언제 안 만드는가
MCP 서버 구축이 적합한 상황과 아닌 상황이 있다.
만들어야 할 때: 내부 API나 데이터베이스를 AI 에이전트가 자연어로 조회·조작해야 하는 경우. 특히 비개발 직군이 "지난주 가입자 수 알려줘" 같은 요청을 슬랙 봇이나 Claude에게 하는 환경이라면 MCP 서버가 딱 맞는다. REST API를 새로 만들 필요 없이 기존 서비스 로직을 감싸기만 하면 된다.
안 만들어도 될 때: 단순 CRUD API를 외부에 제공하는 게 목적이라면 그냥 REST API가 낫다. MCP는 LLM이 클라이언트인 상황에 최적화된 프로토콜이라, 프론트엔드 앱이 직접 호출하는 API까지 MCP로 바꿀 필요는 없다. 기존 OpenAPI 스펙이 잘 정의된 API가 있다면, 그걸 MCP로 감싸는 브릿지 서버를 만드는 것도 방법이다.
pip install "mcp[cli]" 한 줄이면 시작할 수 있고, @mcp.tool() 데코레이터 하나면 기존 함수가 AI 도구가 된다. 이미 Python 백엔드가 있는 상황이라면 하루 안에 프로토타입이 나온다. 내부 도구용 MCP 서버부터 하나 만들어보고, 팀에서 반응이 좋으면 HTTP transport로 확장하는 게 현실적인 순서다.
관련 글
- Claude MCP 서버 직접 만들기 — AI 에이전트 MCP 연동 실무 가이드 – Claude MCP 서버를 직접 만들면서 겪은 삽질을 TIL로 정리했다. stdio transport에서 막힌 이유, Python SDK …
- Python 업무 자동화 실전 가이드 — 엑셀, 이메일, 파일 관리 스크립트 – 매일 반복하던 엑셀 가공, 메일 발송, 폴더 정리를 Python 스크립트 3개로 대체한 과정이다. openpyxl, smtplib, pat…
- FastAPI REST API 실전 구축기 — 인증, 에러 처리, Docker 배포까지 – 레거시 Flask 서버 40개 엔드포인트를 FastAPI REST API로 전환한 과정이다. JWT 인증 구현, Pydantic 모델 도입…