목차
\n\n\n\n- \n
- 0. 이 코드가 왜 안 되는 건데 \n
- 1. MCP가 뭔지 30초 정리 \n
- 2. 개발 환경 세팅과 프로젝트 구조\n
- \n
- SDK 버전 선택 \n
\n - 3. 첫 번째 MCP 서버 만들기 — 로컬 DB 조회 툴\n
- \n
- tool 스키마가 왜 중요한가 \n
\n - 4. Transport 이해하기 — stdio vs SSE\n
- \n
- 디버깅 방법 \n
\n - 5. 실전 — 파일 검색 툴 추가와 리소스 활용\n
- \n
- Resource도 쓸 수 있다 \n
\n - 6. SSE로 배포하기\n
- \n
- 보안 주의사항 \n
\n - 7. 개발하면서 새로 알게 된 것들 \n
- 8. 전체 흐름 정리와 다음 단계 \n
0. 이 코드가 왜 안 되는 건데
\n\n\n\n// claude_desktop_config.json — 처음에 이렇게 썼다\n{\n "mcpServers": {\n "my-tool": {\n "command": "python",\n "args": ["server.py"]\n }\n }\n}\n\n\n\n\n금요일 오후, 이 설정으로 Claude Desktop을 재시작했는데 아무 일도 안 일어났다. 로그를 까보니 McpError: Connection refused가 찍혀 있었다. Python 경로 문제인가 싶어서 절대 경로로 바꿔봤고, python3로도 바꿔봤다. 안 됐다. 1시간 30분을 이것저것 바꾸다가 결국 원인을 찾았는데 — 허탈하게도 가상환경 활성화 문제였다.
// 이렇게 바꾸니까 바로 됐다\n{\n "mcpServers": {\n "my-tool": {\n "command": "uv",\n "args": ["run", "server.py"],\n "cwd": "/Users/me/projects/mcp-server"\n }\n }\n}\n\n\n\n\nuv run이 가상환경을 자동으로 잡아주기 때문이다. 프론트엔드 하다가 백엔드로 넘어온 입장에서, 이런 런타임 환경 문제는 아직도 적응이 안 된다. React 프로젝트에서 npx가 알아서 해주던 것처럼, Python 쪽에도 uv가 그 역할을 하는 거였다.
오늘은 이 삽질을 계기로 MCP 서버를 처음부터 끝까지 만들어본 과정을 정리한다. AI 에이전트 MCP 연동 실무에서 실제로 부딪히는 것들 위주다.
\n\n\n\n1. MCP가 뭔지 30초 정리
\n\n\n\nModel Context Protocol(MCP)은 Anthropic이 2024년 11월에 공개한 오픈 프로토콜이다. AI 모델이 외부 도구를 호출하는 방식을 표준화한 것인데, 쉽게 말하면 "AI가 쓸 수 있는 USB-C 포트"라고 보면 된다.
\n\n\n\n프론트엔드에서 REST API를 호출하는 것과 비슷한 구조인데, 차이점이 있다. REST는 사람이 "이 엔드포인트를 이 파라미터로 호출해야지"라고 결정하지만, MCP는 AI가 상황에 따라 어떤 툴을 호출할지 스스로 판단한다. 프론트→백엔드 전환하면서 느낀 건데, 이 차이가 생각보다 크다.
\n\n\n| 구분 | \nREST API | \nMCP | \n
|---|---|---|
| 호출 주체 | \n사람(개발자)이 결정 | \nAI 모델이 판단 | \n
| 인터페이스 정의 | \nOpenAPI/Swagger | \nTool schema (JSON Schema) | \n
| 전송 방식 | \nHTTP | \nstdio 또는 SSE | \n
| 상태 관리 | \nStateless(기본) | \nSession 기반 | \n
| 디스커버리 | \n문서 읽어야 함 | \ntools/list로 자동 탐색 | \n
한 줄 요약: MCP는 AI가 도구를 자동으로 발견하고 호출하는 표준 프로토콜이다.
\n\n\n\nMCP 생태계에서 핵심 개념은 세 가지다. Host(Claude Desktop 같은 AI 클라이언트), Client(호스트 안에서 서버와 1:1 연결을 관리하는 커넥터), Server(우리가 만들 커스텀 도구). 오늘 다루는 건 Server 쪽이다.
\n\n\n\n2. 개발 환경 세팅과 프로젝트 구조
\n\n\n\n환경은 macOS + VS Code 기준이다. Python SDK(mcp 패키지)를 쓴다. TypeScript SDK도 있는데, 프론트 출신이라 오히려 TypeScript가 편할 수 있지만 MCP 서버 예제가 Python 쪽이 압도적으로 많아서 Python으로 갔다.
# 프로젝트 초기화 (uv 사용 권장)\nuv init mcp-local-tool\ncd mcp-local-tool\nuv add "mcp[cli]>=1.2.0"\n\n\n\n\n(개인적으로 pip보다 uv가 체감 3~4배 빠르다. 프론트의 pnpm 같은 포지션이라고 보면 된다.)
프로젝트 구조는 이렇게 잡았다:
\n\n\n\nmcp-local-tool/\n├── server.py # MCP 서버 메인\n├── tools/\n│ ├── __init__.py\n│ ├── db_query.py # DB 조회 툴\n│ └── file_search.py # 파일 검색 툴\n├── pyproject.toml\n└── .env # DB 접속 정보 (git에 안 올림)\n\n\n\n\nSDK 버전 선택
\n\n\n\n2026년 3월 기준, Python MCP SDK는 1.2.x 대가 최신이다. 1.0.x에서 1.1.0으로 올라갈 때 FastMCP 클래스가 도입되면서 보일러플레이트가 확 줄었다. 이전 버전의 Server 클래스를 쓰는 예제가 아직 돌아다니는데, FastMCP를 쓰는 게 맞다.
| SDK 버전 | \n주요 변경 | \n서버 클래스 | \n
|---|---|---|
| 0.9.x | \n초기 버전, low-level API | \nServer | \n
| 1.0.0 | \n안정화, 스키마 검증 추가 | \nServer | \n
| 1.1.0 | \nFastMCP 도입, 데코레이터 방식 | \nFastMCP (권장) | \n
| 1.2.x | \nSSE transport 개선, 에러 핸들링 강화 | \nFastMCP | \n
한 줄 요약: uv add "mcp[cli]>=1.2.0" 하고 FastMCP 쓰면 된다.
3. 첫 번째 MCP 서버 만들기 — 로컬 DB 조회 툴
\n\n\n\n가장 먼저 만든 건 로컬 SQLite DB를 조회하는 툴이다. 사내에서 로그 데이터를 SQLite에 쌓고 있었는데, Claude한테 "어제 에러 로그 보여줘"라고 말하면 알아서 쿼리를 짜서 조회하게 하고 싶었다.
\n\n\n\n# server.py\nfrom mcp.server.fastmcp import FastMCP\nimport sqlite3\nimport os\n\n# FastMCP 인스턴스 생성\nmcp = FastMCP("local-db-tool")\n\nDB_PATH = os.getenv("DB_PATH", "./logs.db")\n\n@mcp.tool()\ndef query_logs(\n sql: str,\n limit: int = 100\n) -> str:\n """로컬 SQLite DB에서 로그를 조회한다.\n \n Args:\n sql: 실행할 SELECT 쿼리 (SELECT만 허용)\n limit: 최대 반환 행 수 (기본 100)\n """\n # SELECT만 허용 — 이거 안 넣으면 AI가 DROP TABLE 할 수도 있다\n if not sql.strip().upper().startswith("SELECT"):\n return "ERROR: SELECT 쿼리만 허용된다."\n \n conn = sqlite3.connect(DB_PATH)\n conn.row_factory = sqlite3.Row\n try:\n cursor = conn.execute(sql)\n rows = cursor.fetchmany(limit)\n if not rows:\n return "결과 없음"\n # 컬럼명 포함해서 반환\n columns = rows[0].keys()\n result = " | ".join(columns) + "\\n"\n result += "-" * 40 + "\\n"\n for row in rows:\n result += " | ".join(str(row[col]) for col in columns) + "\\n"\n return result\n except Exception as e:\n return f"쿼리 실행 실패: {e}"\n finally:\n conn.close()\n\n@mcp.tool()\ndef list_tables() -> str:\n """DB에 있는 테이블 목록을 반환한다."""\n conn = sqlite3.connect(DB_PATH)\n cursor = conn.execute(\n "SELECT name FROM sqlite_master WHERE type='table'"\n )\n tables = [row[0] for row in cursor.fetchall()]\n conn.close()\n return "\\n".join(tables) if tables else "테이블 없음"\n\nif __name__ == "__main__":\n mcp.run(transport="stdio") # 로컬 연동은 stdio\n\n\n\n\n여기서 중요한 건 @mcp.tool() 데코레이터의 docstring이다. 이 docstring이 Claude한테 "이 툴이 뭘 하는지" 알려주는 설명이 된다. 대충 쓰면 AI가 엉뚱한 상황에서 이 툴을 호출한다.
tool 스키마가 왜 중요한가
\n\n\n\n프론트 시절에 TypeScript interface 대충 쓰면 런타임에서 터지듯이, MCP tool의 파라미터 타입을 대충 쓰면 AI가 이상한 값을 넣는다. sql: str이라고만 쓰면 Claude가 가끔 JSON을 넣기도 하더라. docstring에 "SELECT 쿼리만 허용"이라고 명시하니까 그런 문제가 사라졌다.
(여담이지만, AI한테 넘기는 스키마를 설계하는 게 사람한테 보여주는 API 문서를 쓰는 것과 묘하게 비슷하면서도 다르다. 사람은 예제 보고 이해하는데, AI는 description을 글자 그대로 따른다.)
\n\n\n\n4. Transport 이해하기 — stdio vs SSE
\n\n\n\n이게 오늘 삽질의 핵심이었다. MCP는 두 가지 transport를 지원한다.
\n\n\n\nstdio: 프로세스의 표준 입출력을 통해 통신한다. Claude Desktop이 MCP 서버를 subprocess로 띄우고, stdin/stdout으로 JSON-RPC 메시지를 주고받는 방식이다. 로컬 전용.
\n\n\n\nSSE (Server-Sent Events): HTTP 기반. 서버를 별도로 띄워놓고 클라이언트가 HTTP로 접속한다. 원격 배포에 쓴다.
\n\n\n\n# stdio 모드 — 로컬에서 Claude Desktop과 연동\nif __name__ == "__main__":\n mcp.run(transport="stdio")\n\n# SSE 모드 — 원격 서버로 배포할 때\nif __name__ == "__main__":\n mcp.run(transport="sse", host="0.0.0.0", port=8080)\n\n\n\n\n처음에 SSE로 만들어놓고 Claude Desktop에 연결하려니까 당연히 안 됐다. Claude Desktop은 설정 파일(claude_desktop_config.json)에서 command로 지정한 프로세스를 직접 spawn하는 방식이라 stdio만 된다. 이걸 몰라서 1시간을 날렸다.
공식 문서에는 이 부분이 "stdio is used for local integrations"라고 한 줄 써있는데, 처음 하는 사람은 이걸 놓치기 쉽다. (공식 문서에 안 나오는 팁이라기보다, 나오긴 하는데 너무 짧게 나와서 지나치는 거다.)
\n\n\n\n디버깅 방법
\n\n\n\nMCP 서버가 안 붙을 때 확인할 것:
\n\n\n\n# 1. 서버가 단독으로 실행되는지 확인\necho '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}' | uv run server.py\n\n# 정상이면 JSON 응답이 stdout에 찍힌다\n\n\n\n\n# 2. Claude Desktop 로그 확인 (macOS)\ntail -f ~/Library/Logs/Claude/mcp*.log\n\n\n\n\n이 두 단계로 대부분의 연결 문제를 잡을 수 있다. 첫 번째에서 에러 나면 서버 코드 문제, 첫 번째는 되는데 Claude Desktop에서 안 되면 config 경로 문제다.
\n\n\n\n5. 실전 — 파일 검색 툴 추가와 리소스 활용
\n\n\n\n툴을 하나 더 추가해봤다. 로컬 프로젝트 디렉토리에서 파일을 검색하는 툴이다.
\n\n\n\n# tools/file_search.py\nimport os\nimport fnmatch\n\ndef search_files(\n directory: str,\n pattern: str = "*",\n max_results: int = 20\n) -> list[dict]:\n """디렉토리에서 패턴에 맞는 파일을 검색한다."""\n results = []\n for root, dirs, files in os.walk(directory):\n # node_modules, .git 같은 건 건너뛴다\n dirs[:] = [d for d in dirs if d not in {\n "node_modules", ".git", "__pycache__", ".venv"\n }]\n for fname in files:\n if fnmatch.fnmatch(fname, pattern):\n fpath = os.path.join(root, fname)\n stat = os.stat(fpath)\n results.append({\n "path": fpath,\n "size": stat.st_size,\n "modified": stat.st_mtime\n })\n if len(results) >= max_results:\n return results\n return results\n\n\n\n\n# server.py에 추가\nfrom tools.file_search import search_files\n\n@mcp.tool()\ndef find_files(\n directory: str,\n pattern: str = "*",\n max_results: int = 20\n) -> str:\n """로컬 디렉토리에서 파일을 검색한다.\n \n Args:\n directory: 검색할 디렉토리 경로\n pattern: 파일명 패턴 (예: '*.py', '*.ts')\n max_results: 최대 결과 수\n """\n files = search_files(directory, pattern, max_results)\n if not files:\n return "일치하는 파일 없음"\n lines = []\n for f in files:\n size_kb = f["size"] / 1024\n lines.append(f"{f['path']} ({size_kb:.1f}KB)")\n return "\\n".join(lines)\n\n\n\n\n이건 간단하다. os.walk 돌면서 패턴 매칭하는 것뿐이다.
Resource도 쓸 수 있다
\n\n\n\nMCP에는 Tool 말고 Resource라는 개념도 있다. Tool은 "행동"이고, Resource는 "데이터"다. 프론트엔드 비유로 하면 Tool은 mutation이고 Resource는 query에 가깝다.
\n\n\n\n@mcp.resource("config://app")\ndef get_app_config() -> str:\n """현재 앱 설정을 반환한다."""\n import json\n with open("config.json") as f:\n return json.dumps(json.load(f), indent=2, ensure_ascii=False)\n\n\n\n\n다만 Resource는 아직 안 써봐서 실무에서 얼마나 유용한지는 모르겠다. Tool만으로도 대부분의 AI 에이전트 MCP 연동 실무 시나리오는 커버된다.
\n\n\n\n6. SSE로 배포하기
\n\n\n\n로컬에서 잘 되는 걸 확인했으면 원격 배포가 다음 단계다. 팀원들이 같은 MCP 서버를 쓰려면 SSE transport로 어딘가에 띄워놔야 한다.
\n\n\n\n# server_remote.py — SSE 모드\nfrom mcp.server.fastmcp import FastMCP\nimport sqlite3\nimport os\n\nmcp = FastMCP("remote-db-tool")\n\n# ... (위에서 만든 tool들 동일하게 등록)\n\nif __name__ == "__main__":\n mcp.run(\n transport="sse",\n host="0.0.0.0",\n port=int(os.getenv("PORT", "8080"))\n )\n\n\n\n\nDocker로 감싸면 이렇다:
\n\n\n\nFROM python:3.12-slim\n\nWORKDIR /app\nCOPY . .\n\n# uv 설치 후 의존성 설치\nRUN pip install uv && uv sync\n\nEXPOSE 8080\nCMD ["uv", "run", "server_remote.py"]\n\n\n\n\n# 빌드 & 실행\ndocker build -t mcp-server .\ndocker run -p 8080:8080 -e DB_PATH=/data/logs.db -v ./data:/data mcp-server\n\n\n\n\nSSE 서버에 연결하는 클라이언트 쪽 설정은 이렇다:
\n\n\n\n{\n "mcpServers": {\n "remote-tool": {\n "url": "http://your-server:8080/sse"\n }\n }\n}\n\n\n\n\n(2026년 3월 기준 Claude Desktop은 원격 MCP를 url 필드로 연결할 수 있게 업데이트되었다. 이전에는 별도 프록시가 필요했다.)
보안 주의사항
\n\n\n\nSSE로 배포하면 인증 문제가 생긴다. MCP 프로토콜 자체에는 인증 레이어가 없어서 직접 구현해야 한다.
\n\n\n\n# 간단한 API 키 인증 미들웨어 (프로덕션에선 OAuth 권장)\nfrom functools import wraps\n\nAPI_KEY = os.getenv("MCP_API_KEY")\n\ndef require_auth(func):\n @wraps(func)\n def wrapper(*args, **kwargs):\n # 실제로는 transport 레벨에서 헤더 검증이 필요하다\n # 이건 개념 수준의 예시\n return func(*args, **kwargs)\n return wrapper\n\n\n\n\n솔직히 인증 부분은 아직 깔끔한 방법을 못 찾았다. MCP spec에 OAuth 2.1 기반 인증이 추가되는 논의가 진행 중인 것으로 보이는데(출처: MCP GitHub Discussions), 아직 표준화가 안 됐다. 프로덕션에 배포하려면 앞단에 nginx reverse proxy를 두고 Bearer token 검증하는 게 현실적이다.
\n\n\n\n7. 개발하면서 새로 알게 된 것들
\n\n\n\n오늘 하루 MCP 서버를 만들면서 알게 된 것들을 메모한다.
\n\n\n\ndocstring이 곧 UX다. Tool의 docstring을 어떻게 쓰느냐에 따라 AI의 호출 정확도가 써보면 크게 달라진다. "DB를 조회한다"보다 "로컬 SQLite DB에서 로그를 조회한다. SELECT 쿼리만 허용"이라고 쓰는 게 낫다. 프론트에서 버튼 텍스트 한 글자에 전환율이 달라지는 것과 비슷한 느낌이다.
\n\n\n\n에러 메시지도 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 자동 생성이랑 비슷한 사고방식인데 — 프론트 경험이 여기서 도움이 됐다.
| 실수 | \n증상 | \n해결 | \n
|---|---|---|
python 대신 uv run 미사용 | \nModuleNotFoundError | \nconfig에서 command를 uv로 변경 | \n
| docstring 미작성 | \nAI가 tool을 안 쓰거나 엉뚱하게 사용 | \n구체적인 docstring 추가 | \n
| SSE 서버를 Claude Desktop에 stdio로 연결 | \nConnection refused | \ntransport 방식 맞추기 | \n
| 예외를 raise로 던짐 | \nAI가 에러 원인 파악 못함 | \n문자열 반환으로 변경 | \n
.env 미로드 | \nDB 경로를 못 찾음 | \npython-dotenv 추가 또는 환경변수 직접 설정 | \n
8. 전체 흐름 정리와 다음 단계
\n\n\n\n오늘 한 것을 순서대로 정리하면 이렇다:
\n\n\n\n- \n
uv init으로 프로젝트 생성,mcp[cli]설치 \nFastMCP로 서버 인스턴스 만들고@mcp.tool()데코레이터로 도구 등록 \ntransport="stdio"로 로컬 테스트 \nclaude_desktop_config.json에 서버 등록 (command는uv) \n- 동작 확인 후 SSE transport로 원격 배포 버전 작성 \n
- Docker로 패키징 \n
MCP 서버를 직접 만들어보니, 결국 핵심은 "AI한테 좋은 인터페이스를 설계하는 것"이었다. REST API 설계할 때 엔드포인트 이름, 파라미터, 에러 응답을 신경 쓰듯이 — MCP tool도 이름, docstring, 반환값을 신경 써야 한다. 다만 AI 에이전트 MCP 연동 실무에서 인증과 권한 관리 부분은 아직 표준이 잡히지 않았고, 이 부분은 더 지켜봐야 한다.
\n\n\n\n\n관련 글
\n\n\n\n- \n
- Claude Code 실전 활용법 \n\n\n
- Claude API Python 연동 가이드 \n\n\n
- AI 에이전트 자동화 \n