시리즈 2편. 첫 번째로 만든
bitbucket-mcp를 파헤친다. 구조적으로는 다섯 개 중 가장 단순하지만, 이 글에서 계속 돌아가게 될 4분리 패턴(HTTP / 도메인 / 프로토콜 / CLI)이 여기서 처음 정착했다.
출발점은 귀찮아서... 회사에서 Bitbucket으로 PR을 올리면 CodeRabbit이 자동으로 코드 리뷰를 달아주는데, 이게 꽤 길다. 요약, 워크스루, 인라인 코멘트, 일반 코멘트, 파일별 제안… 브라우저에서 스크롤하면서 보다 보면 PR 하나 리뷰 읽는 데 10분이 간다.
내가 원한 건 Claude Code에게 "1번 PR 리뷰 가져와서 심각한 것만 정리해줘"라고 말하고 끝내는 것. 그러려면 Claude가 CodeRabbit의 코멘트를 볼 수 있어야 하고, 그러려면 MCP가 필요했다.
범위가 좁고 목적이 뚜렷해서 첫 MCP로는 완벽한 크기였다. 너무 작지도 너무 크지도 않고, 외부 API도 한 종류(Bitbucket Cloud REST 2.0)만 건들면 됐다.
list_pull_requests PR 목록 (state/query/limit 필터)
get_pr PR 메타 (숫자 또는 URL로 지정)
get_pr_comments 전체 코멘트 요약 리스트
get_coderabbit_review 마크다운 포맷 리뷰 (주력)
주력은 get_coderabbit_review 하나. 나머지 셋은 탐색용 보조다. "지난주 open 상태 PR 중에 대시보드 관련된 거 있어?" → list_pull_requests → "그중 247번 리뷰 보여줘" → get_coderabbit_review. 이런 흐름.
네 개 모두 문자열 하나를 반환한다. JSON이 아니다. 이 결정이 왜 중요한지는 뒤에서 다시 다룬다.
시간을 가장 많이 쓴 부분. 처음엔 당연히 이렇게 썼다:
headers: { Authorization: `Bearer ${token}` }
Atlassian API Token에 Bitbucket 스코프까지 다 붙여서 만든 토큰이니까 Bearer가 통할 거라고 믿었다. 401이 돌아왔다. 스코프를 의심하고, 워크스페이스 권한을 의심하고, 토큰 재발급까지 해봤다. 전부 틀렸다.
정답:
const auth = Buffer.from(`${email}:${token}`).toString('base64');
headers: { Authorization: `Basic ${auth}` }
스코프 포함 Atlassian API Token이어도, Bitbucket Cloud는 Basic auth(email:token)로만 받는다. Jira와 Confluence는 같은 토큰을 Bearer로도 받는데 Bitbucket만 유독 다르다. 공식 문서를 여러 번 읽어도 이 차이가 눈에 잘 안 들어온다. 테스트해봐야 아는 부분이다.
교훈 하나 — Atlassian 제품군이라고 인증 방식이 같을 거라는 보장이 없다. 시리즈 다음 편의 Jira/Confluence는 Bearer와 Basic이 둘 다 통하는데 Bitbucket만 이 모양이다.
이 MCP의 핵심 설계 결정이 여기다. 파일을 넷으로 쪼갰고, 각자의 책임을 엄격하게 가뒀다:
| 파일 | 책임 | 라인 |
|---|---|---|
bitbucket-client.ts | HTTP + 페이지네이션 + 타입 | ~140 |
coderabbit.ts | 순수 함수: 판정·정제·포매팅 | ~180 |
index.ts | MCP 프로토콜 바인딩 | ~180 |
cli.ts | 사용자 대면 커맨드 | ~200 |
가장 중요한 건 bitbucket-client.ts와 coderabbit.ts 사이의 선이다.
export class BitbucketClient {
constructor(private config: BitbucketConfig) {
const auth = Buffer.from(`${config.email}:${config.token}`).toString('base64');
this.headers = { Authorization: `Basic ${auth}`, Accept: 'application/json' };
}
private async request<T>(url: string): Promise<T> { /* fetch + 에러 처리 */ }
private async paginate<T>(initial: string, limit?: number): Promise<T[]> { /* next 커서 순회 */ }
getPullRequest(prId, ws?, repo?)
listPullRequestComments(prId, ws?, repo?)
listPullRequests(opts)
}
이 클래스는 CodeRabbit이 뭔지 모른다. 그냥 Bitbucket의 PR과 코멘트를 가져올 뿐이다. 포매팅도 안 한다. 판정도 안 한다.
paginate가 별도로 있는 이유는 Bitbucket API가 next 커서를 쓰기 때문이다. 100개씩 끊어서 내려주는데, 내부에서 모아 한 번에 돌려준다.
isCodeRabbitComment(comment) // content.raw에 coderabbit.ai 마커 있는지
cleanBody(raw) // [ ](https://coderabbit.ai/)<!-- ... --> 제거
extractHeadline(body) // _🧹 Nitpick_ + **제목** → "🧹 Nitpick — 제목"
extractCodeRabbitReview(comments) // {walkthrough, inlineComments[], generalComments[]}
formatReview(pr, review, options) // 최종 마크다운 문자열
전부 순수 함수다. 입력 받아서 출력 내놓는다. fetch도 없고, 파일도 안 읽고, MCP SDK도 임포트하지 않는다.
이 분리가 주는 실질적인 이득:
cleanBody에 샘플 문자열 넣고 기대값 비교하면 끝. HTTP 모킹 필요 없음.cleanBody의 정규식만 고치면 된다.isCodeRabbitComment 한 함수에만 있다. 흩어져 있으면 바꿀 때마다 검색해야 한다.파일 분리 자체가 미덕은 아니다. 도메인 지식이 있는 정제 단계가 있을 때만 분리에 의미가 있다. 1편에서도 언급했지만 jira-mcp는 이런 단계가 없어서 포매팅을 클라이언트 안에 녹였다. CodeRabbit은 달랐다 — 전체 코멘트에서 특정 소스만 골라내고, 마커를 제거하고, 헤드라인을 추출하고, 종류별로 재그룹핑하는 도메인 파이프라인이 있었다. 분리해야 했다.
MCP 도구의 반환 형식은 표준적으로 이렇다:
return { content: [{ type: 'text', text: markdownString }] }
처음엔 어색했다. 도구가 구조화된 데이터를 주고 클라이언트가 알아서 해석하는 게 맞지 않나? 왜 문자열?
써보니까 답이 나온다. LLM에게 JSON 배열을 넘기면 모든 항목에 같은 키가 반복된다. 코멘트 30개를 [{id, author, body, timestamp, replies, ...}, ...]로 넘기면 "author":, "body": 같은 키 이름만으로도 컨텍스트가 불어난다. 사람이 읽는 마크다운 리스트로 넘기면:
**@john** _2025-04-20_
> 이 로직은 null 체크가 빠진 것 같습니다.
같은 정보인데 훨씬 압축적이고, Claude의 후속 추론도 더 정확해진다. LLM은 어차피 내부적으로 사람이 읽는 형태로 파싱한다. 개발자가 대신 정리해주는 셈.
그래서 formatReview(pr, review, options)가 존재한다. 이 함수가 데이터 모델을 마크다운으로 변환한다 — 제목, 상태, 워크스루 섹션, 인라인 코멘트를 파일 경로별로 묶고, 일반 코멘트는 뒤에 붙이고, Nitpick / Suggestion / Issue 같은 카테고리 이모지를 유지하면서.
모든 MCP에서 이 원칙을 따랐다. HTTP 응답은 JSON으로 받고, Claude에 넘기기 전에 사람이 읽는 포맷으로 정제한다. 예외 없음.
cli.ts는 commander도 yargs도 안 쓴다. process.argv.slice(2)로 직접 파싱한다. 서브커맨드 5개:
bitbucket-mcp setup # readline 대화형, config.json 0600 저장
bitbucket-mcp register # claude mcp add --env KEY=VAL 실행
bitbucket-mcp status # config 마스킹 표시 + claude mcp list
bitbucket-mcp list-prs [--state --query --limit]
bitbucket-mcp fetch-review <pr> [--summary]
왜 라이브러리를 안 썼나? 서브커맨드 5개는 직접 파싱하는 게 commander를 끌어오는 것보다 싸다. 몇십 줄이면 끝난다. 의존성 추가는 가볍지 않다 — 설치 시간, 업데이트 관리, 취약점 패치, 번들 크기. 5개 MCP 전체에서 외부 런타임 의존성은 @modelcontextprotocol/sdk 하나로 유지됐다.
setup은 readline.createInterface로 Q&A 흐름을 만든다. 기존 값이 있으면 기본값으로 제시하고, 엔터만 치면 유지되게. register는 내부에서 execSync('claude mcp add ...')를 호출해서 Claude Code CLI에 붙인다. 아래 나오는 긴 명령어를 사용자가 직접 외울 필요 없이 register 한 번이면 되게 하는 게 목적.
list-prs와 fetch-review는 CLI 서브커맨드인데, 내부적으로는 MCP 서버를 거치지 않고 BitbucketClient와 formatReview를 직접 임포트해서 쓴다.
// cli.ts
import { BitbucketClient } from './bitbucket-client.js';
import { extractCodeRabbitReview, formatReview } from './coderabbit.js';
const fetchReview = async (prRef: string, opts) => {
const client = new BitbucketClient(config);
const pr = await client.getPullRequest(prId);
const comments = await client.listPullRequestComments(prId);
const review = extractCodeRabbitReview(comments);
console.log(formatReview(pr, review, opts));
};
이 덕분에 Claude Code 없이 터미널에서도 같은 결과를 낼 수 있다. 디버깅할 때 유용하다 — MCP 레이어에 버그가 있는지, HTTP 레이어에 버그가 있는지, 포매팅에 버그가 있는지 터미널 한 줄로 분리해서 확인할 수 있다.
원리는 아주 단순하다. 로직을 순수 함수와 클래스로 만들어 두면, 그 위에 프로토콜 래퍼(MCP)를 얹든 CLI 래퍼를 얹든 똑같이 쓸 수 있다. MCP 자체는 I/O 방식일 뿐, 핵심 로직이 되어선 안 된다.
리뷰 조회 한 번의 전체 경로를 그리면:
Claude Code
│ (JSON-RPC over stdio)
▼
index.ts (dispatcher)
│
├─ parsePrRef("1") → { workspace, repo, prId }
│
▼
bitbucket-client.ts
│ GET /pullrequests/1 → PR
│ GET /pullrequests/1/comments → Comment[] (페이지네이션)
│
▼
coderabbit.ts
│ extractCodeRabbitReview(comments)
│ formatReview(pr, review, options)
│
▼
return { content: [{ type: 'text', text: markdownString }] }
│
▼
Claude Code가 텍스트 수신 → 모델 컨텍스트에 주입
네 파일이 각자의 책임만 들고 데이터를 넘긴다. 한 파일이 두 책임을 맡은 부분이 없다. 이게 유지보수 시에 제일 고맙다 — HTTP 관련 수정은 bitbucket-client.ts만, 포매팅 수정은 coderabbit.ts만, 도구 추가는 index.ts만.
package.json은 최소한만:
{
"type": "module",
"bin": { "bitbucket-mcp": "dist/cli.js" },
"scripts": {
"build": "tsc && chmod +x dist/cli.js",
"start": "node dist/index.js"
},
"dependencies": { "@modelcontextprotocol/sdk": "^1.12.1" }
}
tsc 한 번 돌리면 dist/*.js가 생긴다. 번들러 없음. register 서브커맨드가 내부에서 실행하는 Claude Code 등록 명령:
claude mcp add \
--transport stdio \
--env BITBUCKET_WORKSPACE=my-workspace \
--env BITBUCKET_REPO=my-repo \
--env BITBUCKET_EMAIL=... \
--env BITBUCKET_TOKEN=... \
--scope user \
bitbucket -- \
node /Users/<user>/.claude/mcp-servers/bitbucket-mcp/dist/index.js
--scope user면 모든 프로젝트에서 쓸 수 있고, 설정은 ~/.claude.json에 간다. 등록 후 Claude Code에서 mcp__bitbucket__get_coderabbit_review 같은 이름으로 노출된다.
설정 우선순위는 이렇다:
환경변수 (register 시 --env로 주입된 것)
↓ 없으면
config.json (setup이 0600으로 저장한 것)
↓ 없으면
process.exit(1)
setup은 로컬 개발/디버깅용, Claude Code가 프로덕션에서 띄우는 경로는 env 기반. 같은 바이너리가 두 용도를 다 커버한다.
나중에 만든 MCP 네 개가 전부 이 골격의 파생이다. 문자 그대로 복사해서 시작한 건 아니지만, 머릿속에서 바로 꺼낼 수 있는 템플릿이 됐다:
@modelcontextprotocol/sdk 하나로 끝처음에 스코프가 좁았던 게 오히려 행운이었다. 도구 4개, API 한 종류, 도메인 하나. 이 작은 캔버스 안에서 패턴을 발견했고, 그 패턴을 다른 넷으로 가져갔다.
작게 시작하길 잘했다. 만약 postman-mcp부터 만들었다면(stateful, 변수 치환, 바디 모드 3종이 한꺼번에 들어오는 놈이다) 구조를 못 정리한 채로 복잡도에 끌려다녔을 거다.
3편 — jira-mcp: Jira의 설명과 코멘트가 평문이 아니라 ADF라는 중첩 JSON으로 내려오는 이야기. 읽을 때는 재귀로 텍스트만 뽑아내고, 쓸 때는 반대로 평문을 ADF로 래핑해야 한다. 그리고 Jira에는 왜 REST 엔드포인트가 두 개(/rest/api/3과 /rest/agile/1.0)인지에 대해 알아봊바.