Python으로 MCP 서버 만들기 - 원리부터 구현까지
MCP(Model Context Protocol)가 어떤 원리로 동작하는지 이해하고, Python SDK로 직접 MCP 서버를 구현한다.
MCP란?
MCP(Model Context Protocol) 는 LLM 애플리케이션과 외부 도구·데이터를 연결하는 표준 프로토콜이다. Anthropic이 공개했고, 지금은 Claude뿐 아니라 여러 LLM 클라이언트가 지원한다.
LLM에게 외부 기능을 붙이려면 원래는 각 애플리케이션마다 연동 코드를 따로 짜야 했다.
[Claude Desktop] ──┐
[IDE 플러그인] ──┼── 각각 따로 연동 코드 작성 ← M × N 문제
[자체 챗봇] ──┘ (DB, 파일, API, ...)
MCP는 이 사이에 표준 규격을 하나 끼워넣는다.
[Claude Desktop] ──┐
[IDE 플러그인] ──┼── MCP ──┬── DB 서버
[자체 챗봇] ──┘ ├── 파일 서버
└── API 서버
서버를 한 번 만들면 MCP를 지원하는 모든 클라이언트에서 재사용할 수 있다. USB-C에 비유되는 이유다.
동작 원리
구성 요소 3가지
| 구성 요소 | 역할 | 예시 |
|---|---|---|
| Host | LLM이 들어있는 애플리케이션 | Claude Desktop, IDE |
| Client | Host 안에서 서버와 1:1로 통신하는 커넥터 | Host가 내부적으로 생성 |
| Server | 도구·데이터를 제공하는 프로그램 | 우리가 만들 것 |
[Host (Claude Desktop)]
├── Client A ──── Server A (날씨 조회)
└── Client B ──── Server B (DB 검색)
Host는 서버 하나당 클라이언트 하나를 만들어 연결한다.
통신 방식: JSON-RPC 2.0
MCP의 모든 메시지는 JSON-RPC 2.0 형식이다. 요청에 id를 붙여 보내고, 같은 id로 응답을 받는 단순한 구조다.
// 클라이언트 → 서버: "도구 목록 줘"
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
// 서버 → 클라이언트: 응답
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "get_weather",
"description": "도시의 현재 날씨를 조회한다",
"inputSchema": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
]
}
}
전송 계층(Transport)
JSON-RPC 메시지를 실제로 어떻게 주고받을지는 두 가지 방식이 있다.
| 방식 | 동작 | 용도 |
|---|---|---|
| stdio | 클라이언트가 서버를 자식 프로세스로 실행, 표준 입출력으로 통신 | 로컬 서버 (가장 흔함) |
| Streamable HTTP | HTTP POST + SSE 스트리밍 | 원격 서버 |
stdio 방식이 재미있는 부분이다. Claude Desktop이 우리가 만든 Python 스크립트를 직접 실행하고, stdin으로 JSON을 써넣고 stdout으로 읽는다. 네트워크가 전혀 필요 없다.
[Claude Desktop]
│ 프로세스 실행: python server.py
│
│ stdin ──→ {"jsonrpc":"2.0","id":1,"method":"tools/call",...}
│ stdout ←── {"jsonrpc":"2.0","id":1,"result":{...}}
▼
[server.py]
그래서 stdio 서버에서는 print() 디버깅을 하면 안 된다. stdout이 곧 통신 채널이라 프로토콜이 깨진다. 로그는 stderr로 보내야 한다.
초기화 핸드셰이크
연결되면 가장 먼저 서로의 버전과 기능(capability)을 교환한다.
Client Server
│── initialize (버전, 지원 기능) ──→│
│←─ 응답 (버전, 서버 기능) ─────────│
│── notifications/initialized ────→│
│ │
│── tools/list ───────────────────→│ 이후 실제 통신
서버가 제공하는 것 3가지
| 기능 | 누가 제어 | 설명 |
|---|---|---|
| Tools | 모델 | LLM이 판단해서 호출하는 함수 (예: 날씨 조회, DB 쓰기) |
| Resources | 애플리케이션 | LLM에게 읽기 전용으로 제공하는 데이터 (예: 파일, 스키마) |
| Prompts | 사용자 | 사용자가 선택하는 프롬프트 템플릿 |
대부분의 서버는 Tools만 구현해도 충분하다.
전체 흐름
사용자가 "서울 날씨 어때?"라고 물으면 이런 일이 일어난다.
1. Host가 tools/list로 받아둔 도구 목록을 LLM에게 전달
2. LLM이 판단: "get_weather(city='서울')를 호출해야겠다"
3. Client가 서버에 tools/call 요청
4. 서버가 함수 실행 후 결과 반환
5. LLM이 결과를 보고 자연어로 답변 생성
핵심은 도구를 고르는 건 LLM이고, 실행하는 건 서버라는 점이다. MCP는 그 사이의 통신 규약일 뿐이다.
Python으로 서버 구현하기
설치
공식 Python SDK는 mcp 패키지다. 고수준 API인 FastMCP가 포함되어 있다.
pip install "mcp[cli]"
가장 작은 서버
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
def get_weather(city: str) -> str:
"""도시의 현재 날씨를 조회한다."""
# 실제로는 외부 API를 호출하겠지만, 예시는 더미 데이터
data = {"서울": "맑음 23°C", "부산": "흐림 21°C"}
return data.get(city, f"{city}의 날씨 정보가 없습니다")
if __name__ == "__main__":
mcp.run() # 기본값: stdio
이게 전부다. FastAPI와 비슷한 데코레이터 스타일이라 익숙할 것이다.
데코레이터가 해주는 일
@mcp.tool() 하나가 꽤 많은 일을 한다.
@mcp.tool()
def get_weather(city: str) -> str:
"""도시의 현재 날씨를 조회한다."""
│
├── 함수 이름 → 도구 이름 "get_weather"
├── docstring → 도구 설명 (LLM이 읽고 판단하는 근거)
├── 타입 힌트 → inputSchema (JSON Schema 자동 생성)
└── 반환값 → tools/call 응답으로 직렬화
Pydantic이 타입 힌트를 JSON Schema로 변환해준다. FastAPI가 타입 힌트로 요청을 검증하는 것과 같은 원리다.
여기서 docstring과 파라미터 이름이 곧 인터페이스다. LLM은 이 텍스트만 보고 도구를 호출할지 결정하므로, 설명을 대충 쓰면 도구가 안 불리거나 엉뚱하게 불린다.
Resources와 Prompts 추가
@mcp.resource("config://app")
def get_config() -> str:
"""앱 설정을 제공한다."""
return '{"version": "1.0", "env": "prod"}'
@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str:
"""유저 프로필을 제공한다."""
return f"유저 {user_id}의 프로필"
@mcp.prompt()
def review_code(code: str) -> str:
"""코드 리뷰 프롬프트 템플릿."""
return f"다음 코드를 리뷰해줘:\n\n{code}"
Resource는 URI 템플릿(users://{user_id}/profile)으로 동적 경로도 지원한다. REST API의 path parameter와 같은 개념이다.
비동기 도구
외부 API를 호출하는 도구는 async로 만들면 된다.
import httpx
@mcp.tool()
async def get_real_weather(city: str) -> str:
"""실제 날씨 API를 호출한다."""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.example.com/weather",
params={"q": city},
)
resp.raise_for_status()
return resp.text
FastMCP가 내부적으로 asyncio 이벤트 루프 위에서 돌기 때문에 동기/비동기 함수 모두 그대로 등록할 수 있다.
테스트와 연결
MCP Inspector로 테스트
서버를 LLM에 붙이기 전에 브라우저에서 직접 호출해볼 수 있다.
mcp dev server.py
Inspector UI가 열리고, 도구 목록 확인 → 파라미터 입력 → 호출 결과 확인까지 가능하다. Swagger UI와 비슷한 역할이다.
Claude Desktop에 연결
설정 파일에 서버 실행 명령을 등록하면 끝난다.
// claude_desktop_config.json
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["C:/projects/mcp-weather/server.py"]
}
}
}
Claude Desktop을 재시작하면 get_weather 도구가 보이고, "서울 날씨 알려줘"라고 물으면 LLM이 알아서 도구를 호출한다.
클라이언트도 만들 수 있다
서버만 만드는 게 아니라, 자체 애플리케이션에서 MCP 서버를 호출하는 클라이언트도 SDK로 만들 수 있다.
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize() # 핸드셰이크
tools = await session.list_tools() # tools/list
print([t.name for t in tools.tools])
result = await session.call_tool( # tools/call
"get_weather", {"city": "서울"}
)
print(result.content)
asyncio.run(main())
앞에서 본 JSON-RPC 흐름(initialize → tools/list → tools/call)이 코드에 그대로 드러난다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| MCP | LLM과 외부 도구를 연결하는 표준 프로토콜 |
| JSON-RPC 2.0 | 모든 메시지의 형식 |
| stdio | 로컬 서버의 기본 전송 방식, stdout이 통신 채널 |
| Tools / Resources / Prompts | 서버가 제공하는 기능 3종 |
| FastMCP | 데코레이터로 서버를 만드는 Python 고수준 API |
| docstring + 타입 힌트 | LLM이 도구를 이해하는 유일한 근거 |
원리만 놓고 보면 MCP는 "JSON-RPC로 함수 목록을 알려주고, 호출 요청을 받아 실행해주는 프로토콜"이다. FastMCP 덕분에 구현은 FastAPI 엔드포인트 하나 만드는 수준으로 단순하다. 어렵게 느껴졌다면 그건 프로토콜이 아니라 생태계 용어(Host, Client, Transport) 때문이었을 가능성이 크다.