Python/MCP

Python으로 MCP 서버 만들기 - 원리부터 구현까지

MCP(Model Context Protocol)가 어떤 원리로 동작하는지 이해하고, Python SDK로 직접 MCP 서버를 구현한다.

2026-06-07
11 min read
#Python#MCP#LLM#JSON-RPC#FastMCP

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가지

구성 요소역할예시
HostLLM이 들어있는 애플리케이션Claude Desktop, IDE
ClientHost 안에서 서버와 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 HTTPHTTP 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)이 코드에 그대로 드러난다.


정리

개념한 줄 요약
MCPLLM과 외부 도구를 연결하는 표준 프로토콜
JSON-RPC 2.0모든 메시지의 형식
stdio로컬 서버의 기본 전송 방식, stdout이 통신 채널
Tools / Resources / Prompts서버가 제공하는 기능 3종
FastMCP데코레이터로 서버를 만드는 Python 고수준 API
docstring + 타입 힌트LLM이 도구를 이해하는 유일한 근거

원리만 놓고 보면 MCP는 "JSON-RPC로 함수 목록을 알려주고, 호출 요청을 받아 실행해주는 프로토콜"이다. FastMCP 덕분에 구현은 FastAPI 엔드포인트 하나 만드는 수준으로 단순하다. 어렵게 느껴졌다면 그건 프로토콜이 아니라 생태계 용어(Host, Client, Transport) 때문이었을 가능성이 크다.