Python MCP SDK로 MCP 서버 만들기: MCPServer 도구 정의부터 Claude Code 연결까지
v2의 MCPServer로 도구·리소스·프롬프트를 정의하고, Inspector로 확인하고, stdio로 연결하는 순서와 v1(FastMCP) 코드에서 바뀐 점

핵심 요약
- Python SDK v2는 FastMCP 대신 MCPServer를 쓰며, Python 3.11 이상과 mcp[cli] 설치가 필요하다.
- 도구의 입력 스키마와 설명은 타입 힌트와 docstring에서 만들어지므로 Literal 등으로 입력 범위를 좁힌다.
- v2에서는 Context를 핸들러 인자로 받고, host·port는 run()에 넘긴다.
- stdio 서버는 stdout에 아무것도 출력하지 말고 로그를 stderr로 보낸다.
- Claude Code에는 claude mcp add --transport stdio <이름> -- <명령>으로 등록한다.
사내 API나 데이터베이스 조회를 AI 에이전트 도구로 노출하고 싶은데, 팀의 백엔드가 파이썬이라 TypeScript SDK 예제를 그대로 쓰기 어려운 개발자를 위한 글이다. 이 글을 따라가면 공식 Python SDK로 도구 두 개짜리 MCP 서버를 만들고, MCP Inspector로 동작을 확인하고, Claude Code에 연결하는 데까지 진행할 수 있다. 검색하면 많이 나오는 FastMCP 예제가 현재 SDK에서 어떻게 바뀌었는지도 함께 정리한다.
먼저 알아 둘 것: FastMCP에서 MCPServer로
현재 공식 SDK는 v2이며, 마이그레이션 가이드에 따르면 v1의 FastMCP 클래스가 MCPServer로 이름이 바뀌었다. 블로그나 예전 문서의 코드를 붙여 넣으면 import 단계에서 실패할 수 있으므로 차이를 먼저 확인한다.
| 항목 | v1 | v2 |
|---|---|---|
| 서버 클래스 | from mcp.server.fastmcp import FastMCP | from mcp.server import MCPServer (또는 mcp.server.mcpserver) |
| host·port 등 전송 설정 | 생성자 인자 | run() 인자 |
| Context 얻기 | mcp.get_context() | 핸들러 인자 ctx: Context로 주입 |
| 생성자 위치 인자 | name, instructions, ... | name, title, description, instructions, ... — 두 번째 위치 인자가 title이 됨 |
| 동기 핸들러 | 이벤트 루프에서 실행 | 워커 스레드에서 실행 |
| 예외 클래스 | McpError | MCPError |
생성자 위치 인자 변경은 오류 없이 의미만 바뀌므로 특히 주의한다. v1 코드의 FastMCP("이름", "지시문")을 클래스 이름만 바꾸면 지시문이 title로 들어간다. v2에서는 instructions=처럼 키워드로 넘긴다.
프로젝트 준비
SDK는 Python 3.11 이상이 필요하다. 공식 README는 uv로 mcp[cli]를 추가하는 방법을 안내한다. [cli]를 넣어야 mcp dev, mcp run 명령을 쓸 수 있다.
uv init ticket-mcp
cd ticket-mcp
uv add "mcp[cli]"
# pip를 쓴다면: pip install "mcp[cli]"
uv run python --version # 3.11 이상인지 확인
도구·리소스·프롬프트 정의하기
예제로 사내 티켓 시스템을 조회하는 서버를 만든다. SDK는 함수의 타입 힌트와 docstring으로 도구의 입력 스키마와 설명을 만든다. 따라서 매개변수 타입과 docstring을 구체적으로 쓰는 것이 곧 도구 설계다.
# server.py
import sys
import logging
from typing import Literal
from mcp.server import MCPServer, Context
logging.basicConfig(stream=sys.stderr, level=logging.INFO) # stdout 금지
log = logging.getLogger("ticket-mcp")
mcp = MCPServer("ticket-mcp", instructions="사내 티켓 조회 전용 서버. 쓰기 작업 없음.")
TICKETS = {
"T-101": {"title": "로그인 오류", "status": "open", "owner": "kim"},
"T-102": {"title": "결제 지연", "status": "closed", "owner": "lee"},
}
@mcp.tool()
def get_ticket(ticket_id: str) -> dict:
"""티켓 ID(예: T-101)로 제목·상태·담당자를 조회한다. 없으면 오류를 반환한다."""
if ticket_id not in TICKETS:
raise ValueError(f"존재하지 않는 티켓: {ticket_id}")
return TICKETS[ticket_id]
@mcp.tool()
async def list_tickets(status: Literal["open", "closed"], ctx: Context) -> list[str]:
"""상태별 티켓 ID 목록을 반환한다."""
await ctx.info(f"list_tickets status={status}")
return [k for k, v in TICKETS.items() if v["status"] == status]
@mcp.resource("ticket://{ticket_id}")
def ticket_resource(ticket_id: str) -> str:
"""티켓 상세를 텍스트로 제공한다."""
t = TICKETS.get(ticket_id)
return f"{ticket_id} {t['title']} ({t['status']})" if t else "not found"
@mcp.prompt()
def triage(ticket_id: str) -> str:
"""티켓 분류 요청 프롬프트."""
return f"{ticket_id} 티켓을 읽고 긴급도와 담당 팀을 제안해 줘."
if __name__ == "__main__":
mcp.run(transport="stdio")
코드에서 짚을 부분은 세 가지다. Literal["open", "closed"]로 타입을 좁히면 스키마에 허용값이 들어가 모델이 엉뚱한 값을 넣는 일이 줄어든다. ctx: Context는 v2 방식대로 인자로 받는다. 로그는 stderr로 보낸다. stdio 전송에서는 표준 출력이 프로토콜 메시지 통로이므로 print() 한 줄이 연결을 깨뜨린다. 도구 이름·입력 범위·오류 반환을 설계하는 기준은 MCP 도구 인터페이스 설계에서 따로 다뤘다.
Inspector로 먼저 확인하기
uv run mcp dev server.py를 실행하면 MCP Inspector가 열린다.- Tools 탭에서
get_ticket,list_tickets가 보이는지, 입력 스키마에status의 허용값이 나오는지 확인한다. get_ticket에 없는 ID(T-999)를 넣어 오류가 결과로 돌아오는지 본다.- Resources 탭에서
ticket://T-101을 읽어 본다.
Inspector에서 정상이어야 클라이언트 연결 문제와 서버 문제를 구분할 수 있다. 연결 실패나 스키마 오류를 찾는 순서는 MCP Inspector로 서버 디버깅하기를 참고한다.
Claude Code에 연결하기
Claude Code MCP 문서에 따르면 stdio 서버는 -- 뒤에 실행 명령을 적어 등록한다. uv run --directory로 프로젝트 경로를 지정하면 어느 위치에서 Claude Code를 실행해도 같은 가상 환경으로 서버가 뜬다.
claude mcp add --transport stdio ticket -- \
uv run --directory /abs/path/ticket-mcp python server.py
claude mcp list # 등록 확인
# Claude Code 세션 안에서 /mcp 로 연결 상태 확인
팀원과 같은 설정을 공유하려면 --scope project로 등록해 .mcp.json에 남긴다. 범위별 차이와 환경 변수 처리는 Claude Code에 MCP 서버 연결하기에 정리했다.
원격으로 배포할 때 바뀌는 것
여러 사람이 같은 서버를 쓰려면 stdio 대신 Streamable HTTP로 띄운다. README의 예시는 uv run mcp run server.py --transport streamable-http이고, 코드에서는 mcp.run(transport="streamable-http", host=..., port=...)처럼 v2 규칙대로 전송 설정을 run()에 넘긴다. 원격 서버는 인증이 필수 과제가 되므로 MCP 서버에 OAuth 붙이기의 항목을 함께 확인한다. TypeScript로 같은 구조를 만드는 방법은 TypeScript SDK로 최소 MCP 서버 만들기에 있다.
초보자가 자주 실수하는 포인트
- v1 FastMCP 예제를 그대로 붙여 넣어 import 오류가 나는 경우
- FastMCP("이름", "지시문")을 MCPServer로만 바꿔 지시문이 title로 들어가는 경우
- 디버깅용 print()를 남겨 stdio 연결이 깨지는 경우
- mcp만 설치하고 [cli]를 빼서 mcp dev 명령이 없는 경우
체크리스트
- Python 3.11 이상 환경인가
- 모든 도구에 타입 힌트와 구체적인 docstring이 있는가
- 로그가 stderr로만 나가는가
- Inspector에서 도구 목록·스키마·오류 응답을 확인했는가
- Claude Code에서 /mcp로 연결 상태를 확인했는가
자주 묻는 질문
독립 패키지 fastmcp와 공식 SDK는 같은 건가요?
별개 프로젝트입니다. 공식 SDK v1에 포함됐던 FastMCP 클래스와 이름이 같아 혼동이 많습니다. 이 글은 modelcontextprotocol 조직의 공식 python-sdk 기준이며, 다른 패키지를 쓴다면 해당 프로젝트 문서의 API를 따라야 합니다.
동기 함수와 async 함수 중 무엇으로 도구를 만들어야 하나요?
v2에서는 동기 핸들러가 워커 스레드에서 실행되므로 블로킹 라이브러리를 그대로 써도 이벤트 루프를 막지 않습니다. 다만 동기 핸들러 안에서 asyncio.get_running_loop()를 호출하는 코드는 동작하지 않으므로, 비동기 클라이언트를 쓰는 도구는 async def로 작성합니다.
참고 자료 · 검증 기준
- MCP Python SDK — GitHub README
- MCP Python SDK Docs
- MCP Python SDK Docs — Migration guide (v1 → v2)
- Claude Code Docs — Connect Claude Code to tools via MCP
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.