# Build MCP Server

> 외부 서비스(메일·채팅·드라이브·이슈 트래커 등)를 에이전트에 붙일 때, 범용 API 통로 대신 목적별 도구만 노출하는 작은 MCP 서버를 처음부터 끝까지 만든다. 서버·설치 스크립트·게이트 테스트 작성, 변형 주입으로 게이트 실증, 실제 응답으로 출력 검증, 자격 증명을 코드 밖에 600 권한으로 보관, 쓰는 하네스 전부에 등록하고 목록 대조까지 한 흐름으로 처리한다. 사용자가 "MCP 서버 만들어줘", "내가 직접 MCP 만들게", "슬랙/드라이브/캘린더 붙여줘", "이 API를 MCP로 감싸줘", "에이전트가 아무거나 못 하게 도구를 좁히고 싶다", "커스텀 MCP 서버" 같은 표현을 쓰면 반드시 이 스킬을 사용한다. 이미 있는 MCP 서버를 설치·등록만 하는 일(그건 각 하네스의 mcp 명령), 스킬을 만드는 일(build-skill)에는 쓰지 않는다.

- Skill: `gagip/build-mcp-server` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add gagip/build-mcp-server`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gagip/build-mcp-server/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: gagip (https://skillmd.com/u/gagip)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/gagip/build-mcp-server

---


# 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. 게이트를 들어오는 경로마다 넣는다

허용 목록 판정은 함수 하나로 만들고, 외부 식별자를 받는 **모든** 도구가 그 함수를 지나게 한다.

```python
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`을 가짜 함수로 갈아끼우고 게이트만 본다.
허용/차단 양쪽을 다 넣는다. 막기만 하고 정작 허용된 것도 못 읽으면 서버가 무용지물이다.

통과를 봤으면 거기서 멈추지 말고 **게이트를 무력화해 실패를 확인한다.**

```bash
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`를 쓴다:

```bash
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 명령을 쓴다. 실행 중인 프로세스가 같은 파일을
덮어써서 편집이 사라진다. 파이썬과 스크립트 경로는 **절대 경로**로 준다 — 서버는 작업 디렉터리가
어디일지 모르는 상태로 실행된다.

```bash
python3 <이 스킬>/scripts/check_registration.py
```

이 스크립트가 각 도구의 등록 목록을 뽑아 이름·명령이 어긋난 곳을 짚어 준다.

등록해도 **이미 떠 있는 세션에는 안 붙는다.** 새 대화를 시작해야 도구로 잡히므로, 보고할 때
"등록은 됐고 이 세션에서는 아직 안 보인다"를 명시한다. 이 세션에서 동작을 확인하려면 위
`probe.py`로 서버를 직접 실행해 본다.

### 8. 색인과 셸 정리

서버 모음 폴더를 안내하는 문서(라우터·README)가 있으면 새 서버 폴더를 그 목록에 추가한다.

그리고 **1번에서 정한 좁히기가 실제로 성립하는지 마지막으로 본다** — 같은 API를 부를 수 있는
토큰이 셸 환경 변수나 CLI 로그인으로 남아 있으면 서버는 우회 가능한 장식이다. 걷어낸 뒤
실제로 막혔는지 확인한다.

```bash
<그 서비스 CLI> <아무 조회 명령>     # not_authed / 인증 없음 이 나와야 한다
```

원본 셸 설정은 백업해 두고, 백업본 권한도 조인다.

---

## 완료 보고

무엇을 검증했는지 구체로 적는다. 특히 아래 넷은 빠뜨리기 쉬워서 명시적으로 남긴다.

```
- 게이트: 차단 N건 / 허용 M건 통과, 게이트 제거 시 차단 N건이 실제로 실패함
- 실제 응답: <어느 항목>에서 <몇 건> 받아 출력 확인 (또는 "실제 데이터 미확인" + 이유)
- 자격 증명: <경로> 권한 600, 코드 폴더 밖
- 등록: <하네스 목록> 각각 등록됨 / 새 세션부터 도구로 잡힘
```

확인 못 한 항목은 "미검증"으로 남긴다. 조용히 빠뜨리는 것과 밝히고 빠뜨리는 것은 읽는 사람에게
전혀 다른 정보다.

---

## 행동 원칙

- **도구를 늘려 달라는 요청에는 좁히기 축을 먼저 다시 확인한다.** "이것도 볼 수 있게 해줘"가
  쌓이면 결국 범용 통로가 된다. 새 도구가 허용 목록 안에서만 도는지 매번 본다.
- **허용 목록을 코드에 상수로 두되 환경 변수로 덮을 수 있게 한다.** 테스트가 실제 목록과
  무관하게 돌 수 있어야 하고, 사용자가 서버를 안 고치고도 항목을 바꿀 수 있다.
- **읽을 수 없는 항목의 내용을 이름으로 단정하지 않는다.** 이름이 그럴듯한 채널이 실제로는
  비어 있고, 무심한 이름의 채널에 정작 원하는 게 쌓여 있는 일이 흔하다. 근거가 이름뿐이면
  그렇게 밝히고 사용자에게 확인한다.
- **구조화 질문 도구가 없는 하네스면** 선택지를 대화 문장으로 제시하고 답을 기다린다. 절차
  자체는 파일 조작과 bash만 쓰므로 하네스를 가리지 않는다.

