Claude MCP 서버 직접 만들기 — AI 에이전트 MCP 연동 실무 가이드

목차

0. 이 코드가 왜 안 되는 건데

// claude_desktop_config.json — 처음에 이렇게 썼다
{
  "mcpServers": {
    "my-tool": {
      "command": "python",
      "args": ["server.py"]
    }
  }
}

Claude Desktop에 MCP 서버를 연결하고 재시작했는데 아무 일도 안 일어났다. 로그에 McpError: Connection refused가 쌓여 있었고, Python 경로를 절대 경로로 바꿔봐도, python3으로 바꿔봐도 안 됐다. 원인은 가상환경 활성화 문제였다.

// 이렇게 바꾸니까 바로 됐다
{
  "mcpServers": {
    "my-tool": {
      "command": "uv",
      "args": ["run", "server.py"],
      "cwd": "/Users/me/projects/mcp-server"
    }
  }
}

uv run이 가상환경을 자동으로 잡아주기 때문이다. 프론트엔드 하다가 백엔드로 넘어온 입장에서, 이런 런타임 환경 문제는 아직도 적응이 안 된다. React 프로젝트에서 npx가 알아서 해주던 것처럼, Python 쪽에도 uv가 그 역할을 하는 거였다.

MCP 서버��� 처음부터 끝까지 만드는 과정을 다룬다. 실무에서 실제로 부딪히는 것들 위주다.

1. MCP가 뭔지 30초 정리

Model Context Protocol(MCP)은 Anthropic이 2024년 11월에 공개한 오픈 프로토콜이다. AI 모델이 외부 도구를 호출하는 방식을 표준화한 것인데, 쉽게 말하면 "AI가 쓸 수 있는 USB-C 포트"라고 보면 된다.

프론트엔드에서 REST API를 호출하는 것과 비슷한 구조인데, 차이점이 있다. REST는 사람이 "이 엔드포인트를 이 파라미터로 호출해야지"라고 결정하지만, MCP는 AI가 상황에 따라 어떤 툴을 호출할지 스스로 판단한다. 프론트→백엔드 전환하면서 느낀 건데, 이 차이가 생각보다 크다.

구분 REST API MCP
호출 주체 사람(개발자)이 결정 AI 모델이 판단
인터페이스 정의 OpenAPI/Swagger Tool schema (JSON Schema)
전송 방식 HTTP stdio 또는 SSE
상태 관리 Stateless(기본) Session 기반
디스커버리 문서 읽어야 함 tools/list로 자동 탐색

한 줄 요약: MCP는 AI가 도구를 자동으로 발견하고 호출하는 표준 프로토콜이다.

MCP 생태계에서 핵심 개념은 세 가지다. Host(Claude Desktop 같은 AI 클라이언트), Client(호스트 안에서 서버와 1:1 연결을 관리하는 커넥터), Server(우리가 만들 커스텀 도구). 오늘 다루는 건 Server 쪽이다.

2. 개발 환경 세팅과 프로젝트 구조

환경은 macOS + VS Code 기준이다. Python SDK(mcp 패키지)를 쓴다. TypeScript SDK도 있는데, 프론트 출신이라 오히려 TypeScript가 편할 수 있지만 MCP 서버 예제가 Python 쪽이 압도적으로 많아서 Python으로 갔다.

# 프로젝트 초기화 (uv 사용 권장)
uv init mcp-local-tool
cd mcp-local-tool
uv add "mcp[cli]>=1.2.0"

(개인적으로 pip보다 uv가 체감 3~4배 빠르다. 프론트의 pnpm 같은 포지션이라고 보면 된다.)

프로젝트 구조는 이렇게 잡았다:

mcp-local-tool/
├── server.py          # MCP 서버 메인
├── tools/
│   ├── __init__.py
│   ├── db_query.py    # DB 조회 툴
│   └── file_search.py # 파일 검색 툴
├── pyproject.toml
└── .env               # DB 접속 정보 (git에 안 올림)

SDK 버전 선택

2026년 3월 기준, Python MCP SDK는 1.2.x 대가 최신이다. 1.0.x에서 1.1.0으로 올라갈 때 FastMCP 클래스가 도입되면서 보일러플레이트가 확 줄었다. 이전 버전의 Server 클래스를 쓰는 예제가 아직 돌아다니는데, FastMCP를 쓰는 게 맞다.

SDK 버전 주요 변경 서버 클래스
0.9.x 초기 버전, low-level API Server
1.0.0 안정화, 스키마 검증 추가 Server
1.1.0 FastMCP 도입, 데코레이터 방식 FastMCP (권장)
1.2.x SSE transport 개선, 에러 핸들링 강화 FastMCP

한 줄 요약: uv add "mcp[cli]>=1.2.0" 하고 FastMCP 쓰면 된다.

3. 첫 번째 MCP 서버 만들기 — 로컬 DB 조회 툴

가장 먼저 만든 건 로컬 SQLite DB를 조회하는 툴이다. 사내에서 로그 데이터를 SQLite에 쌓고 있었는데, Claude한테 "어제 에러 로그 보여줘"라고 말하면 알아서 쿼리를 짜서 조회하게 하고 싶었다.

# server.py
from mcp.server.fastmcp import FastMCP
import sqlite3
import os

# FastMCP 인스턴스 생성
mcp = FastMCP("local-db-tool")

DB_PATH = os.getenv("DB_PATH", "./logs.db")

@mcp.tool()
def query_logs(
    sql: str,
    limit: int = 100
) -> str:
    """로컬 SQLite DB에서 로그를 조회한다.
    
    Args:
        sql: 실행할 SELECT 쿼리 (SELECT만 허용)
        limit: 최대 반환 행 수 (기본 100)
    """
    # SELECT만 허용 — 이거 안 넣으면 AI가 DROP TABLE 할 수도 있다
    if not sql.strip().upper().startswith("SELECT"):
        return "ERROR: SELECT 쿼리만 허용된다."
    
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        cursor = conn.execute(sql)
        rows = cursor.fetchmany(limit)
        if not rows:
            return "결과 없음"
        # 컬럼명 포함해서 반환
        columns = rows[0].keys()
        result = " | ".join(columns) + "\n"
        result += "-" * 40 + "\n"
        for row in rows:
            result += " | ".join(str(row[col]) for col in columns) + "\n"
        return result
    except Exception as e:
        return f"쿼리 실행 실패: {e}"
    finally:
        conn.close()

@mcp.tool()
def list_tables() -> str:
    """DB에 있는 테이블 목록을 반환한다."""
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table'"
    )
    tables = [row[0] for row in cursor.fetchall()]
    conn.close()
    return "\n".join(tables) if tables else "테이블 없음"

if __name__ == "__main__":
    mcp.run(transport="stdio")  # 로컬 연동은 stdio

여기서 중요한 건 @mcp.tool() 데코레이터의 docstring이다. 이 docstring이 Claude한테 "이 툴이 뭘 하는지" 알려주는 설명이 된다. 대충 쓰면 AI가 엉뚱한 상황에서 이 툴을 호출한다.

tool 스키마가 왜 중요한가

프론트 시절에 TypeScript interface 대충 쓰면 런타임에서 터지듯이, MCP tool의 파라미터 타입을 대충 쓰면 AI가 이상한 값을 넣는다. sql: str이라고만 쓰면 Claude가 가끔 JSON을 넣기도 하더라. docstring에 "SELECT 쿼리만 허용"이라고 명시하니까 그런 문제가 사라졌다.

(여담이지만, AI한테 넘기는 스키마를 설계하는 게 사람한테 보여주는 API 문서를 쓰는 것과 묘하게 비슷하면서도 다르다. 사람은 예제 보고 이해하는데, AI는 description을 글자 그대로 따른다.)

4. Transport 이해하기 — stdio vs SSE

이게 오늘 삽질의 핵심이었다. MCP는 두 가지 transport를 지원한다.

stdio: 프로세스의 표준 입출력을 통해 통신한다. Claude Desktop이 MCP 서버를 subprocess로 띄우고, stdin/stdout으로 JSON-RPC 메시지를 주고받는 방식이다. 로컬 전용.

SSE (Server-Sent Events): HTTP 기반. 서버를 별도로 띄워놓고 클라이언트가 HTTP로 접속한다. 원격 배포에 쓴다.

# stdio 모드 — 로컬에서 Claude Desktop과 연동
if __name__ == "__main__":
    mcp.run(transport="stdio")

# SSE 모드 — 원격 서버로 배포할 때
if __name__ == "__main__":
    mcp.run(transport="sse", host="0.0.0.0", port=8080)

처음에 SSE로 만들어놓고 Claude Desktop에 연결하려니까 당연히 안 됐다. Claude Desktop은 설정 파일(claude_desktop_config.json)에서 command로 지정한 프로세스를 직접 spawn하는 방식이라 stdio만 된다. 이걸 몰라서 1시간을 날렸다.

공식 문서에는 이 부분이 "stdio is used for local integrations"라고 한 줄 써있는데, 처음 하는 사람은 이걸 놓치기 쉽다. (공식 문서에 안 나오는 팁이라기보다, 나오긴 하는데 너무 짧게 나와서 지나치는 거다.)

디버깅 방법

MCP 서버가 안 붙을 때 확인할 것:

# 1. 서버가 단독으로 실행되는지 확인
echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}' | uv run server.py

# 정상이면 JSON 응답이 stdout에 찍힌다
# 2. Claude Desktop 로그 확인 (macOS)
tail -f ~/Library/Logs/Claude/mcp*.log

이 두 단계로 대부분의 연결 문제를 잡을 수 있다. 첫 번째에서 에러 나면 서버 코드 문제, 첫 번째는 되는데 Claude Desktop에서 안 되면 config 경로 문제다.

5. 실전 — 파일 검색 툴 추가와 리소스 활용

툴을 하나 더 추가해봤다. 로컬 프로젝트 디렉토리에서 파일을 검색하는 툴이다.

# tools/file_search.py
import os
import fnmatch

def search_files(
    directory: str,
    pattern: str = "*",
    max_results: int = 20
) -> list[dict]:
    """디렉토리에서 패턴에 맞는 파일을 검색한다."""
    results = []
    for root, dirs, files in os.walk(directory):
        # node_modules, .git 같은 건 건너뛴다
        dirs[:] = [d for d in dirs if d not in {
            "node_modules", ".git", "__pycache__", ".venv"
        }]
        for fname in files:
            if fnmatch.fnmatch(fname, pattern):
                fpath = os.path.join(root, fname)
                stat = os.stat(fpath)
                results.append({
                    "path": fpath,
                    "size": stat.st_size,
                    "modified": stat.st_mtime
                })
                if len(results) >= max_results:
                    return results
    return results
# server.py에 추가
from tools.file_search import search_files

@mcp.tool()
def find_files(
    directory: str,
    pattern: str = "*",
    max_results: int = 20
) -> str:
    """로컬 디렉토리에서 파일을 검색한다.
    
    Args:
        directory: 검색할 디렉토리 경로
        pattern: 파일명 패턴 (예: '*.py', '*.ts')
        max_results: 최대 결과 수
    """
    files = search_files(directory, pattern, max_results)
    if not files:
        return "일치하는 파일 없음"
    lines = []
    for f in files:
        size_kb = f["size"] / 1024
        lines.append(f"{f['path']} ({size_kb:.1f}KB)")
    return "\n".join(lines)

이건 간단하다. os.walk 돌면서 패턴 매칭하는 것뿐이다.

Resource도 쓸 수 있다

MCP에는 Tool 말고 Resource라는 개념도 있다. Tool은 "행동"이고, Resource는 "데이터"다. 프론트엔드 비유로 하면 Tool은 mutation이고 Resource는 query에 가깝다.

@mcp.resource("config://app")
def get_app_config() -> str:
    """현재 앱 설정을 반환한다."""
    import json
    with open("config.json") as f:
        return json.dumps(json.load(f), indent=2, ensure_ascii=False)

다만 Resource는 아직 안 써봐서 실무에서 얼마나 유용한지는 모르겠다. Tool만으로도 대부분의 AI 에이전트 MCP 연동 실무 시나리오는 커버된다.

6. SSE로 배포하기

로컬에서 잘 되는 걸 확인했으면 원격 배포가 다음 단계다. 팀원들이 같은 MCP 서버를 쓰려면 SSE transport로 어딘가에 띄워놔야 한다.

# server_remote.py — SSE 모드
from mcp.server.fastmcp import FastMCP
import sqlite3
import os

mcp = FastMCP("remote-db-tool")

# ... (위에서 만든 tool들 동일하게 등록)

if __name__ == "__main__":
    mcp.run(
        transport="sse",
        host="0.0.0.0",
        port=int(os.getenv("PORT", "8080"))
    )

Docker로 감싸면 이렇다:

FROM python:3.12-slim

WORKDIR /app
COPY . .

# uv 설치 후 의존성 설치
RUN pip install uv && uv sync

EXPOSE 8080
CMD ["uv", "run", "server_remote.py"]
# 빌드 & 실행
docker build -t mcp-server .
docker run -p 8080:8080 -e DB_PATH=/data/logs.db -v ./data:/data mcp-server

SSE 서버에 연결하는 클라이언트 쪽 설정은 이렇다:

{
  "mcpServers": {
    "remote-tool": {
      "url": "http://your-server:8080/sse"
    }
  }
}

(2026년 3월 기준 Claude Desktop은 원격 MCP를 url 필드로 연결할 수 있게 업데이트되었다. 이전에는 별도 프록시가 필요했다.)

보안 주의사항

SSE로 배포하면 인증 문제가 생긴다. MCP 프로토콜 자체에는 인증 레이어가 없어서 직접 구현해야 한다.

# 간단한 API 키 인증 미들웨어 (프로덕션에선 OAuth 권장)
from functools import wraps

API_KEY = os.getenv("MCP_API_KEY")

def require_auth(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # 실제로는 transport 레벨에서 헤더 검증이 필요하다
        # 이건 개념 수준의 예시
        return func(*args, **kwargs)
    return wrapper

솔직히 인증 부분은 아직 깔끔한 방법을 못 찾았다. MCP spec에 OAuth 2.1 기반 인증이 추가되는 논의가 진행 중인 것으로 보이는데(출처: MCP GitHub Discussions), 아직 표준화가 안 됐다. 프로덕션에 배포하려면 앞단에 nginx reverse proxy를 두고 Bearer token 검증하는 게 현실적이다.

7. 개발하면서 새로 알게 된 것들

오늘 하루 MCP 서버를 만들면서 알게 된 것들을 메모한다.

docstring이 곧 UX다. Tool의 docstring을 어떻게 쓰느냐에 따라 AI의 호출 정확도가 써보면 크게 달라진다. "DB를 조회한다"보다 "로컬 SQLite DB에서 로그를 조회한다. SELECT 쿼리만 허용"이라고 쓰는 게 낫다. 프론트에서 버튼 텍스트 한 글자에 전환율이 달라지는 것과 비슷한 느낌이다.

에러 메시지도 AI가 읽는다. raise Exception("에러")가 아니라 return "ERROR: SELECT 쿼리만 허용된다. DROP, INSERT 등은 사용 불가."처럼 문자열로 반환하는 게 좋다. 예외를 던지면 AI가 "도구 실행에 실패했다"고만 인식하는데, 문자열로 반환하면 "아, SELECT만 되는구나"라고 이해하고 쿼리를 고쳐서 다시 호출한다.

FastMCP의 타입 추론. 함수 시그니처에 타입 힌트를 쓰면 FastMCP가 자동으로 JSON Schema를 생성해준다. sql: str이라고 쓰면 {"type": "string"}이 되고, limit: int = 100이라고 쓰면 {"type": "integer", "default": 100}이 된다. 이건 TypeScript의 Zod schema 자동 생성이랑 비슷한 사고방식인데 — 프론트 경험이 여기서 도움이 됐다.

실수 증상 해결
python 대신 uv run 미사용 ModuleNotFoundError config에서 command를 uv로 변경
docstring 미작성 AI가 tool을 안 쓰거나 엉뚱하게 사용 구체적인 docstring 추가
SSE 서버를 Claude Desktop에 stdio로 연결 Connection refused transport 방식 맞추기
예외를 raise로 던짐 AI가 에러 원인 파악 못함 문자열 반환으로 변경
.env 미로드 DB 경로를 못 찾음 python-dotenv 추가 또는 환경변수 직접 설정

8. 전체 흐름 정리와 다음 단계

오늘 한 것을 순서대로 정리하면 이렇다:

  1. uv init으로 프로젝트 생성, mcp[cli] 설치
  2. FastMCP로 서버 인스턴스 만들고 @mcp.tool() 데코레이터로 도구 등록
  3. transport="stdio"로 로컬 테스트
  4. claude_desktop_config.json에 서버 등록 (command는 uv)
  5. 동작 확인 후 SSE transport로 원격 배포 버전 작성
  6. Docker로 패키징

MCP 서버를 직접 만들어보니, 결국 핵심은 "AI한테 좋은 인터페이스를 설계하는 것"이었다. REST API 설계할 때 엔드포인트 이름, 파라미터, 에러 응답을 신경 쓰듯이 — MCP tool도 이름, docstring, 반환값을 신경 써야 한다. 다만 AI 에이전트 MCP 연동 실무에서 인증과 권한 관리 부분은 아직 표준이 잡히지 않았고, 이 부분은 더 지켜봐야 한다.

관련 글

Chiko
Chiko

Platform Engineer. Python, AI, Infra에 관심이 많습니다.