
Python으로 첫 MCP 서버 구축하기: Claude에게 당신만의 노트 제공하기
요약
Python과 공식 SDK를 사용하여 Model Context Protocol(MCP) 서버를 구축하는 초보자용 가이드입니다. 약 60줄의 코드로 도구를 구현하고 Claude Code와 연결하는 방법을 설명하며, stdio 전송 계층 사용 시 주의사항을 다룹니다.
핵심 포인트
- MCP를 통해 한 번의 구현으로 다양한 AI 클라이언트와 통합 가능
- uv와 Python SDK를 활용한 간결한 MCP 서버 구축 방법
- Claude Code에 MCP 서버를 등록하는 명령어 및 절차
- stdio 전송 계층 사용 시 stdout 관련 주의사항(footgun) 안내
Python으로 첫 MCP 서버 구축하기: Claude에게 당신만의 노트 제공하기
Model Context Protocol (MCP)에 대한 초보자 가이드입니다. uv와 공식 SDK를 사용하여 약 60줄의 Python 코드로 실제 MCP 서버를 구축하고, stdio를 통해 두 가지 도구 (tools)를 노출하며, 단 한 번의 명령으로 Claude Code에 연결해 보세요. 블록 버퍼링 (block buffering) 뒤에 숨어 있다가 나중에 문제를 일으킬 수 있는 stdout 관련 주의사항(footgun)도 포함되어 있습니다.
이 사이트에는 48개의 포스트가 있습니다. 저는 같은 내용을 두 번 다시 Google에서 검색하지 않기 위해 모든 포스트를 직접 작성했고, 이는 효과가 있었습니다. Netplan 구문이나 정확한 ufw 명령어가 필요할 때, Stack Overflow를 헤매는 대신 이곳으로 옵니다.
짜증 나는 점은 제가 대화하고 있는 모델이 이 글들을 전혀 읽지 못했다는 것입니다. 모델은 Netplan의 일반적인 형태는 알고 있지만, 제가 세 번의 시행착오 끝에 결정한 버전은 알지 못합니다. 채팅창에 포스트를 붙여넣을 수는 있고, 실제로 여러 번 그렇게 했습니다. 하지만 붙여넣기는 시스템이 아닙니다. 그것은 매 세션마다 영원히 반복해야 하는 작업이며, 어떤 포스트가 필요한지 이미 알고 있을 때만 작동합니다.
제가 실제로 원하는 것은 문(door)입니다. 모델이 무언가 필요할 때 노크하게 하고, 모델이 직접 노트를 읽게 하는 것입니다.
그 문이 바로 **Model Context Protocol (MCP)**이며, 유용한 부분은 프로토콜 그 자체가 아닙니다. 통합 (integration) 과정을 한 번만 작성하면 된다는 점입니다. MCP가 없다면 모델에게 노트에 대한 접근 권한을 주는 것은 도구마다, 클라이언트마다 맞춤형 통합을 구현해야 함을 의미합니다. Claude Code를 위한 것 하나, 데스크톱 앱을 위한 것 하나, 그리고 내년에 사용할 무언가를 위한 또 다른 하나가 필요합니다. MCP를 사용하면 당신의 노트를 검색하는 방법을 아는 서버를 하나만 작성하면 되고, 해당 프로토콜을 지원하는 모든 클라이언트가 이를 호출할 수 있습니다. 서버는 누가 요청하는지 알 필요도 없고 상관하지도 않습니다.
이 포스트는 그 서버를 구축합니다. 약 60줄의 Python 코드로 이루어져 있습니다.
요약 (TL;DR)
uv add "mcp[cli]"를 실행하고, 두 개의 함수에@mcp.tool()데코레이터를 적용한 뒤, 파일 끝에mcp.run(transport="stdio")를 추가하세요. 그 다음claude mcp add notes -- uv run --directory /abs/path server.py명령어로 등록합니다. 연결되지 않는다면 반드시 절대 경로를 사용해야 하며,stdout은 전송 계층 (transport)으로 사용되므로 절대print()를 통해stdout에 출력해서는 안 됩니다.
시작하기 전에
uv와 Claude Code가 필요합니다. 그게 전부입니다. 배포할 서버도, Docker도, 열어야 할 포트도 없습니다. 로컬 MCP 서버는 Claude Code가 서브프로세스 (subprocess)로 실행하여 stdin 및 stdout을 통해 통신하는 단순한 프로그램일 뿐입니다.
마지막 포인트는 충분히 곱씹어 볼 가치가 있습니다. 사람들은 이 과정이 복잡할 것이라고 예상하지만, 실제로는 그렇지 않기 때문입니다. MCP는 두 가지 전송 계층 (transports)을 정의합니다. Streamable HTTP는 많은 클라이언트가 네트워크를 통해 연결하는 원격 서버용이며, 전체 인증 (authentication) 과정을 포함합니다. stdio는 로컬 서버용이며, 하나의 파이프 (pipe)입니다. 당신의 프로세스, 클라이언트의 프로세스, 그리고 그 사이를 오가는 JSON이 전부입니다. 포트에서 대기하는 것도 없고, 외부로 노출되는 것도 없습니다.
아래의 모든 내용은 stdio를 기반으로 합니다. 시작하기에 가장 적합한 방식이며, 개인 노트북의 파일을 읽는 서버라면 이 방식이 최선일 수 있습니다.
무엇을 만드는가
두 가지 도구 (tools)를 만듭니다:
search_notes(query): 특정 문구가 포함된 포스트를 찾아 해당 포스트의 슬러그 (slug)와 제목을 반환합니다.get_note(slug): 포스트 전체 내용을 반환합니다.
이러한 분리는 보기보다 중요합니다. 검색은 작은 리스트를 반환하여 모델이 선택할 수 있게 하고, 그 후 모델은 원하는 것만 가져옵니다. 만약 search_notes가 포스트 본문 전체를 반환한다면, 세 단어짜리 쿼리 하나만으로도 40,000개의 단어가 컨텍스트 윈도우 (context window)에 쏟아져 들어와 모델이 작업을 시작하기도 전에 과부하가 걸릴 것입니다.
저는 제가 가지고 있는 블로그를 대상으로 이 작업을 수행하고 있습니다. NOTES를 어떤 마크다운 (markdown) 폴더로 지정하든 동일하게 작동합니다. 그것이 이 프로젝트의 핵심 아이디어입니다.
프로젝트 설정
mkdir mcp-notes && cd mcp-notes
uv init .
uv add "mcp[cli]"
mcp[cli]는 공식 Python SDK입니다. cli extra는 나중에 필요하게 될 개발 도구들을 함께 제공합니다.
서버
다음은 전체 코드인 server.py입니다:
"""Claude가 내 블로그 포스트를 읽을 수 있게 해주는 MCP 서버.
두 가지 도구: search_notes는 특정 문구가 포함된 포스트를 찾고, get_note는 하나를 반환합니다.
...
이것이 완전한 MCP 서버입니다. 세 가지 부분이 작업을 수행합니다.
FastMCP("notes")는 서버입니다. 이 이름은 클라이언트(clients)에 표시됩니다.
@mcp.tool()은 마법이 일어나는 곳이며, 이것이 여러분의 수고를 얼마나 덜어주는지 이해할 가치가 있습니다. 프로토콜은 각 도구가 자신의 입력을 설명하는 JSON Schema를 광고(advertise)할 것을 요구합니다. FastMCP는 여러분의 **타입 힌트 (type hints)**로부터 해당 스키마를 구축하며, 도구의 설명은 **독스트링 (docstring)**에서 가져옵니다. 여러분은 일반적인 Python 함수를 작성하기만 하면 프로토콜 관련 서류 작업이 자동으로 생성됩니다. 이 포스트가 짧은 이유가 바로 이것입니다.
mcp.run(transport="stdio")는 stdin에서 JSON을 읽고 stdout으로 JSON을 쓰는 루프를 시작합니다.
검색이 대소문자를 구분하지 않는 부분 문자열 일치(case insensitive substring match) 방식이라는 점에 주목하세요. 이것은 제가 마지막에 업그레이드할 플레이스홀더(placeholder)가 아닙니다. 핵심은 이것입니다: 프로토콜을 구현하는 데는 20분이면 충분하지만, 검색 품질은 이 포스트 전체를 다 써도 모자랄 만큼 별개의 문제입니다. 이에 대한 자세한 내용은 아래에서 다룹니다.
Claude Code에 연결하기
단 한 줄의 명령어로 가능합니다:
claude mcp add notes -- uv run --directory /Users/you/projects/mcp-notes server.py
--가 중요합니다. 이 기호 이전의 모든 것은 claude mcp add에 속하며, 이 기호 이후의 모든 것은 Claude Code가 서버를 시작하기 위해 실행하는 실제 명령어입니다. 구분자가 없다면, claude는 여러분의 명령어 플래그(flags)를 자신의 플래그로 파싱하려고 시도할 것입니다.
--directory가 더 중요하며, 이것이 제가 처음에 겪었던 문제입니다. Claude Code는 서버가 위치한 디렉토리가 아니라, 여러분이 claude를 실행한 디렉토리에서 해당 명령어를 실행합니다. 저는 단순히 uv run server.py로 등록하고 제 블로그 리포지토리(repo)에서 Claude를 실행했다가 다음과 같은 결과를 얻었습니다:
notes: uv run server.py - ✘ Failed to connect
당연히 실패했습니다. 제 블로그 리포지토리(repo)에는 server.py가 없기 때문입니다. uv run --directory /abs/path server.py는 경로를 고정(pin)하므로, 이제 Claude를 어디에서 실행하든 상관없습니다.
확인해 보세요:
claude mcp list
notes: uv run --directory /Users/you/projects/mcp-notes server.py - ✔ Connected
✔ Connected는 Claude Code가 프로세스를 실행하고, 핸드셰이크 (handshake)를 완료했으며, 도구 목록 (tool list)을 성공적으로 받아왔음을 의미합니다. 더 자세한 정보는 claude mcp get notes를 통해 범위 (scope), 정확한 명령어, 그리고 발생한 오류 등을 확인할 수 있습니다.
범위 (scope)에 대하여
기본적으로 서버는 로컬 (local) 범위로 추가됩니다. 이는 서버가 사용자에게만 비공개이며, 서버를 추가한 디렉토리에 종속됨을 의미합니다. 이 부분 때문에 저는 1분 정도 혼란을 겪었습니다. 블로그 리포지토리에 있는 상태에서 서버를 추가한 뒤, 서버 자체의 디렉토리로 이동하여 claude mcp list를 실행했더니 목록에 없었기 때문입니다. 고장 난 것도, 오류가 난 것도 아니었습니다. 단지 목록에 나타나지 않았을 뿐입니다. 로컬 범위는 프로젝트 단위로 적용되는데, 저는 다른 프로젝트에 있었던 것입니다.
선택 가능한 옵션은 다음과 같습니다:
--scope local(기본값): 이 프로젝트에만 적용되며,~/.claude.json에 저장됩니다.--scope user: 작업하는 모든 프로젝트에 적용됩니다. 노트 서버의 경우 이 옵션을 사용하는 것이 좋습니다.--scope project: 리포지토리에.mcp.json파일을 작성하여, 해당 리포지토리를 클론 (clone)하는 모든 사람이 서버를 사용할 수 있게 합니다.
사용하기
Claude Code를 시작하고, Claude의 학습 데이터에는 없지만 사용자의 노트에는 있는 내용을 질문해 보세요:
> NVIDIA 드라이버를 설치할 때 Secure Boot에 대해 어떻게 결정했었지?
도구가 처음 실행될 때, Claude Code는 도구당 한 번씩 사용자의 권한 승인을 요청합니다. 승인하면 다시 묻지 않습니다. 이름은 서버별로 네임스페이스 (namespace)가 지정되므로, 제 경우에는 mcp__notes__search_notes 및 mcp__notes__get_note로 표시됩니다. 세션 내부에서 /mcp를 입력하면 서버와 해당 서버의 도구 목록을 볼 수 있습니다.
그러면 작동합니다. Claude는 search_notes("Secure Boot")를 호출하여 슬러그 (slug)를 받아온 뒤, 이를 바탕으로 get_note를 호출합니다. 그리고 제 포스트의 내용을 바탕으로 다음과 같이 답변합니다: "Secure Boot가 켜져 있다면, 드라이버 설치 시 프롬프트가 뜰 때 MOK 키를 등록하거나, 시작하기 전에 BIOS에서 Secure Boot를 끄세요."
그것이 정답이며, NVIDIA 드라이버가 작동하는 방식에 대한 일반적인 인상이 아니라 저의 개인 포스트에서 가져온 것이기에 정확한 답변입니다.
하지만 제가 예상하지 못했던 부분은 그다음에 일어난 일이었습니다. 요청하지 않았음에도 불구하고, 모델은 다시 검색하여 저의 Ubuntu 26.04 데스크톱 보안 강화 (Hardening Ubuntu 26.04 Desktop) 포스트를 찾아냈고, 두 내용이 서로 모순된다는 점을 지적했습니다. 데스크톱 버전에서는 Secure Boot가 켜져 있는 것을 전제로 하는 TPM 기반 전체 디스크 암호화 (Full Disk Encryption)를 권장합니다. 따라서 "Secure Boot를 끄세요"라는 조언은 헤드리스 (Headless) GPU 서버에 국한된 조언이지 일반적인 규칙이 아니며, 저는 두 포스트를 동시에 머릿속에 담아둔 적이 없었기에 이 내용을 어디에도 적어둔 적이 없었습니다.
그 순간이 바로 이 도구가 제값을 하는 순간입니다. 저는 한 달 간격으로 작성된 두 포스트를 화해시키라고 요청하지 않았습니다. 모델은 그저 두 포스트에 모두 접근할 수 있었을 뿐입니다.
단순 검색이 실패하는 지점
약속했던 대로, 이 부분은 제가 예상했던 것보다 더 흥미롭습니다.
구체적인 것을 요청하면 매우 훌륭합니다. search_notes("fail2ban")는 정확히 알맞은 세 개의 포스트를 반환합니다. 하지만 일반적인 단어를 시도해 보세요:
search_notes("firewall")
create-sudo-user-ubuntu-26-04: Create a Sudo User on Ubuntu 26.04
enable-ssh-on-ubuntu-desktop: Enable SSH on an Ubuntu desktop
extend-azure-windows-disk-run-command: Extending C: on locked-down Azure Windows VMs
...
나열된 항목들은 모두 실제 매칭되는 결과입니다. 하지만 실제 방화벽 포스트인 UFW 방화벽 기초 (UFW Firewall Basics)는 목록에 없습니다.
부분 일치 (Substring match)가 실패한 것은 아닙니다. UFW 포스트를 잘 찾아냈습니다. 문제는 랭킹 (Ranking)이 전혀 없다는 점입니다. _posts()는 파일을 알파벳 순서로 반환하고 저는 다섯 개에서 멈추기 때문에, "firewall"을 지나가는 식으로 한 번 언급한 다섯 개의 포스트가 방화벽에 대해 온전히 다루는 포스트를 밀어내 버렸습니다. 순전히 u가 c, e, h보다 뒤에 정렬되기 때문입니다.
이것이 진짜 교훈이며, 제가 더 나은 검색 기능으로 이 문제를 덮어버리지 않은 이유입니다. MCP는 20분 만에 당신의 노트를 모델에게 전달했습니다. 어떤 노트가 적절한 것인지 결정하는 것이 실제 작업이며, 이는 언제나 그래왔던 것과 동일한 검색 문제입니다. 프로토콜은 이 문제를 도와주지 않으며, 앞으로도 도와줄 리 없습니다.
하지만 비용이 전혀 들지 않는 저렴한 해결책이 있으며, 이는 이 포스트 전체에서 가장 MCP다운 부분입니다. 바로 모델에게 그 한계에 대해 알려주는 것입니다. search_notes의 독스트링 (docstring)을 다시 읽어보세요. 거기에는 랭킹 (ranking) 기능이 없다고 명시되어 있으며, 다른 검색어로 여러 번 검색하라고 되어 있습니다. 모델은 이를 읽고 firewall에 대한 결과가 미흡하면 ufw를 시도함으로써 이를 보완합니다. 잘못된 검색을 주석 하나로 고칠 수는 없지만, 모델이 그 결과를 맹신하는 것은 막을 수 있습니다.
내가 겪은 주의사항 (Gotchas)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기