build-mcp-server
외부 서비스를 에이전트에 붙이는 방법은 둘이다. 공식 MCP 서버를 깔아 API 전체를 여는 것, 그리고 필요한 일만 하는 작은 서버를 직접 만드는 것.
이 스킬은 두 번째를 위한 것이다. 사람이 이걸 택하는 이유는 대개 능력을 넓히려는 게 아니라 좁히려는 것이다 — "알림 채널의 서버 경보만 모아 줘"는 원하지만, 그 대가로 에이전트가 워크스페이스 전체를 뒤지고 메시지를 보낼 수 있게 되는 건 원하지 않는다.
핵심 원칙:
- 도구 목록이 곧 권한이다. 범용 조회 도구(
call_api(method, params)같은 것)를 하나라도 두면 좁히기는 전부 무효다. 노출하는 도구는 사람이 실제로 시킬 일 2~4개로 끝낸다. - 막는 자리는 한 곳이 아니다. 목록 조회만 막으면 다른 데서 알아낸 ID로 상세를 열 수 있다. 들어오는 경로마다 같은 기준으로 막는다.
- 자격 증명은 코드 밖, 대화 밖. 값이 에이전트의 대화 기록을 지나가면 그 기록에 영원히 남는다. 사용자가 직접 실행하는 설치 스크립트로 받아, 서버 코드 폴더 밖에 권한 600으로 둔다.
- 셸에 토큰을 남겨 두면 서버는 장식이다. 환경 변수로도 같은 API를 부를 수 있으면 좁힌 게 아니다. 서버가 돌기 시작하면 셸 쪽 자격 증명은 걷어낸다.
실행 절차
1. 무엇을 좁힐지 먼저 정한다
코드를 쓰기 전에 허용 목록의 축을 정한다. 이게 서버 전체의 모양을 결정한다.
축은 그 서비스가 이미 가지고 있는 분류를 빌리는 게 가장 좋다 — 메일이면 라벨, 채팅이면 채널, 드라이브면 폴더, 이슈 트래커면 프로젝트. 사람이 그 서비스의 화면에서 이미 쓰는 단위라 "뭘 열어 뒀는지"를 나중에도 안 헷갈린다.
사용자에게 확인할 것은 대개 하나다 — 어느 항목을 열 것인가. 후보를 추측하지 말고 실제 목록을 조회해 보여준다. 다만 아직 못 읽는 항목의 내용을 이름만 보고 단정하지 말고, 근거가 이름뿐이면 그렇다고 밝힌다.
권한(스코프)도 여기서 정한다. 읽기만 시킬 거면 읽기 전용 스코프만 받는다 — 나중에 도구를 안 만들면 그만이라고 미루면, 자격 증명 자체가 쓰기 가능한 상태로 남는다.
2. 파일 세 개를 만든다
서버 모음 폴더(예: ~/mcp-servers/) 아래 서비스별 폴더를 만들고 세 파일을 둔다.
| 파일 | 하는 일 |
|---|---|
server.py |
stdio JSON-RPC 루프 + 도구 구현 + 게이트 |
setup.py |
사용자가 직접 실행해 자격 증명을 만든다 |
test_gate.py |
허용 목록 밖이 막히는지 검사 |
assets/server.template.py와 assets/setup.template.py를 복사해 시작한다. 표준 라이브러리만
쓴다 — 의존성이 없으면 설치 절차도 없고, 파이썬 경로만 맞으면 어느 하네스에서든 그대로 돈다.
서버는 예외로 죽지 않게 한다. 도구 하나가 터져서 프로세스가 내려가면 그 서버의 도구가
통째로 사라지고, 사용자에게는 "도구가 없다"로만 보인다. 오류는 isError를 실은 정상 응답으로
돌려주고, 사람이 다음에 뭘 해야 하는지(초대·재설치·권한 추가) 문장으로 적는다.
3. 게이트를 들어오는 경로마다 넣는다
허용 목록 판정은 함수 하나로 만들고, 외부 식별자를 받는 모든 도구가 그 함수를 지나게 한다.
def resolve(name):
"""허용된 항목이면 정보를, 아니면 막는다."""
idx = allowed_index()
match = next((n for n in idx if n.lower() == name.lower()), None)
if not match:
raise Blocked(f"'{name}' 은 허용된 항목이 아닙니다. 허용: {', '.join(idx) or '(없음)'}")
return idx[match]
상세 조회가 항목 이름이 아니라 개별 ID를 받는다면(메일 한 통, 메시지 하나), 그 ID가 허용 항목에 속하는지 응답을 받아 본 뒤 판정한다. 목록 도구를 거쳐 얻은 ID라고 가정하지 않는다 — ID는 다른 경로로도 손에 들어온다.
막을 때는 허용 목록을 함께 돌려준다. 그래야 에이전트가 같은 벽에 반복해 부딪히지 않고 다음 시도를 바로잡는다.
4. 게이트를 뜯어내 테스트가 실패하는지 본다
test_gate.py는 API를 부르지 않는다 — api_get을 가짜 함수로 갈아끼우고 게이트만 본다.
허용/차단 양쪽을 다 넣는다. 막기만 하고 정작 허용된 것도 못 읽으면 서버가 무용지물이다.
통과를 봤으면 거기서 멈추지 말고 게이트를 무력화해 실패를 확인한다.
cp server.py /tmp/server.py.orig && shasum server.py # 원본 보관
# 편집 도구로 게이트가 막지 않게 바꾼다 (raise 대신 통과)
python3 test_gate.py # 차단 케이스가 실패해야 정상
cp /tmp/server.py.orig server.py && shasum server.py # 원복 + 해시 대조
차단 케이스가 그대로 통과한다면 테스트가 게이트를 안 보고 있다는 뜻이다. 원복 뒤 해시를 다시 찍어 변형이 남지 않았는지 확인하는 것이 이 절차의 마지막 관문이다.
한글이 든 소스는 sed·heredoc으로 고치지 말고 파일 편집 도구를 쓴다 — 셸을 거치면 글자가
조용히 깨져서, 변형 주입이 실패했는데 "통과"로 보이는 일이 생긴다.
5. 실제 응답 한 건을 떠서 픽스처로 박는다
응답 형식을 상상해서 만든 가짜 데이터는 내 오해를 그대로 통과시킨다. 게이트 테스트가 전부 초록인데 실제로 붙이면 빈 내용만 나오는 일이 여기서 생긴다. 알림·메시지류는 본문이 최상위 필드가 아니라 첨부나 블록 안쪽에 들어 있고, 바깥에는 "미리보기 없음" 같은 자리표시자만 들어 있는 경우가 흔하다.
허용된 항목에서 진짜 응답을 한 건 받아 그대로 픽스처로 저장하고, 그것으로 출력 형식을 검사하는 테스트를 따로 둔다. 무엇이 나와야 하는지(제목·보낸 곳·본문)와 무엇이 나오면 안 되는지 (자리표시자 문구)를 함께 넣는다.
서버를 stdio로 직접 두드려 볼 때는 scripts/probe.py를 쓴다:
python3 <이 스킬>/scripts/probe.py <서버 폴더>/server.py --list
python3 <이 스킬>/scripts/probe.py <서버 폴더>/server.py <도구이름> key=value
6. 자격 증명은 사용자가 직접 넣는다
setup.py는 사용자가 실행한다. 비밀값은 화면에 다시 찍히지 않게 받고(getpass), 받은 즉시
유효한지 확인한 뒤, 코드 폴더 밖에 권한 600으로 저장한다.
~/.config/<서비스>/credentials.json (권한 600)
코드 폴더 밖에 두는 이유는 단순하다 — 폴더째 어딘가에 올리거나 공유해도 자격 증명이 따라가지 않는다.
값이 이미 셸이나 대화에 노출됐다면 파일로 옮기는 것만으로 해결되지 않는다. 그 값은 폐기하고 새로 발급받아 넣는다. 값을 옮긴 뒤 만든 백업 파일도 기본 권한(644)으로 새로 생기므로 원본과 같은 권한으로 조인다.
비밀이 설정됐는지만 확인할 때 값이 확장되는 문법을 쓰지 않는다. ${TOKEN:-없음}은 설정돼
있으면 값 자체를 출력한다. 있는지만 보려면 [ -n "$TOKEN" ] && echo 있음처럼 값이 절대
펼쳐지지 않는 형태를 쓴다.
7. 쓰는 하네스 전부에 등록하고 목록을 대조한다
두 개 이상의 에이전트 도구를 번갈아 쓰는 환경이라면 등록은 도구마다 따로 해야 한다. 한쪽만 하면 반대쪽에서 조용히 없는 채로 남는다 — 이 절차에서 가장 자주 빠지는 단계다.
설정 파일을 손으로 고치지 말고 각 도구의 mcp 명령을 쓴다. 실행 중인 프로세스가 같은 파일을 덮어써서 편집이 사라진다. 파이썬과 스크립트 경로는 절대 경로로 준다 — 서버는 작업 디렉터리가 어디일지 모르는 상태로 실행된다.
python3 <이 스킬>/scripts/check_registration.py
이 스크립트가 각 도구의 등록 목록을 뽑아 이름·명령이 어긋난 곳을 짚어 준다.
등록해도 이미 떠 있는 세션에는 안 붙는다. 새 대화를 시작해야 도구로 잡히므로, 보고할 때
"등록은 됐고 이 세션에서는 아직 안 보인다"를 명시한다. 이 세션에서 동작을 확인하려면 위
probe.py로 서버를 직접 실행해 본다.
8. 색인과 셸 정리
서버 모음 폴더를 안내하는 문서(라우터·README)가 있으면 새 서버 폴더를 그 목록에 추가한다.
그리고 1번에서 정한 좁히기가 실제로 성립하는지 마지막으로 본다 — 같은 API를 부를 수 있는 토큰이 셸 환경 변수나 CLI 로그인으로 남아 있으면 서버는 우회 가능한 장식이다. 걷어낸 뒤 실제로 막혔는지 확인한다.
<그 서비스 CLI> <아무 조회 명령> # not_authed / 인증 없음 이 나와야 한다
원본 셸 설정은 백업해 두고, 백업본 권한도 조인다.
완료 보고
무엇을 검증했는지 구체로 적는다. 특히 아래 넷은 빠뜨리기 쉬워서 명시적으로 남긴다.
- 게이트: 차단 N건 / 허용 M건 통과, 게이트 제거 시 차단 N건이 실제로 실패함
- 실제 응답: <어느 항목>에서 <몇 건> 받아 출력 확인 (또는 "실제 데이터 미확인" + 이유)
- 자격 증명: <경로> 권한 600, 코드 폴더 밖
- 등록: <하네스 목록> 각각 등록됨 / 새 세션부터 도구로 잡힘
확인 못 한 항목은 "미검증"으로 남긴다. 조용히 빠뜨리는 것과 밝히고 빠뜨리는 것은 읽는 사람에게 전혀 다른 정보다.
행동 원칙
- 도구를 늘려 달라는 요청에는 좁히기 축을 먼저 다시 확인한다. "이것도 볼 수 있게 해줘"가 쌓이면 결국 범용 통로가 된다. 새 도구가 허용 목록 안에서만 도는지 매번 본다.
- 허용 목록을 코드에 상수로 두되 환경 변수로 덮을 수 있게 한다. 테스트가 실제 목록과 무관하게 돌 수 있어야 하고, 사용자가 서버를 안 고치고도 항목을 바꿀 수 있다.
- 읽을 수 없는 항목의 내용을 이름으로 단정하지 않는다. 이름이 그럴듯한 채널이 실제로는 비어 있고, 무심한 이름의 채널에 정작 원하는 게 쌓여 있는 일이 흔하다. 근거가 이름뿐이면 그렇게 밝히고 사용자에게 확인한다.
- 구조화 질문 도구가 없는 하네스면 선택지를 대화 문장으로 제시하고 답을 기다린다. 절차 자체는 파일 조작과 bash만 쓰므로 하네스를 가리지 않는다.