시리즈 4편. 세 번째로 만든
confluence-mcp를 파헤친다. jira-mcp와 거의 똑같이 생겼다. 인증도 같고, 파일 구조도 같고, CLI도 같다. 그래서 이 글은 "복제된 코드베이스를 어떻게 다뤘는가"에 대한 이야기에 가깝다. 그리고 Confluence가 가진 두 가지 고유한 짜증 — CQL(Confluence Query Language)과 v1/v2 API 혼용, 특히 사용자가 외운 키와 v2가 요구하는 숫자 ID 사이의 간극.
jira-mcp 만들고 나서 너무 자연스럽게 따라왔다. 같은 Atlassian 토큰 재사용, 같은 base URL 도메인(xxx.atlassian.net), 같은 Basic auth. 회사에서 Jira와 Confluence는 같이 돈다 — 티켓에는 결정 사항이 짧게 들어가고, 자세한 RFC나 회의록은 Confluence에 있다. Jira만 Claude가 볼 수 있고 Confluence는 못 보면 반쪽짜리였다.
그리고 솔직히 말하면, jira-mcp의 골격을 그대로 가져다 쓸 수 있다는 게 컸다. 첫 번째 제대로 된 "복제" 였다. 이 경험이 나중에 swagger-mcp(postman-mcp의 70% 복제)를 만드는 데도 자신감이 됐다.
search_pages CQL 패스스루 검색
get_page 페이지 상세 (제목/본문)
list_spaces 워크스페이스의 스페이스 목록
get_space_pages 특정 스페이스의 페이지 목록 (키 또는 ID)
get_child_pages 하위 페이지
get_recent_pages 최근 수정 페이지
get_my_recent_work 내가 기여한 페이지
jira-mcp의 6개보다 하나 많다. 그리고 모두 조회다. 쓰기가 없다. 페이지 작성/수정 같은 무거운 변경은 의도적으로 빼뒀다 — Jira 코멘트 같은 단발성 글과 달리, Confluence 페이지는 사람이 에디터에서 다듬는 워크플로우가 정착돼 있어서, MCP 도구로 자동 생성하면 오히려 팀 컨벤션을 깨뜨릴 위험이 있었다.
jira-mcp와 confluence-mcp의 파일 트리를 나란히 두면 거의 데칼코마니다:
jira-mcp/ confluence-mcp/
├── package.json ├── package.json
├── tsconfig.json ├── tsconfig.json
├── config.json ├── config.json
├── src/ ├── src/
│ ├── index.ts │ ├── index.ts
│ ├── cli.ts │ ├── cli.ts
│ └── jira-client.ts │ └── confluence-client.ts
└── dist/ └── dist/
<domain>-client.ts 한 글자만 바뀌고, 나머지는 구조가 같다. CLI의 setup/register/status 3단도 같고, 환경변수 폴백 우선순위도 같고, MCP SDK 사용 패턴도 같다.
이 시점에서 자연스럽게 떠오르는 질문 — "그러면 공통 코드를 패키지로 추출해야 하지 않나?"
진지하게 고민했다. @my/atlassian-mcp-base, @my/mcp-cli-runner 같은 걸 만들어서 jira-mcp와 confluence-mcp가 둘 다 import하게 하는 그림. 그런데 결론은 하지 않기로 했다. 이유 셋:
복붙 vs 추상화 사이의 선은 내 안에서 이렇게 정리됐다 — 복사된 코드가 동시에 같은 방향으로 변경되는 빈도가 높을 때만 추상화한다. Jira와 Confluence는 그렇지 않다. 인증 코드 세 줄을 같이 고쳐야 할 일이 1년에 두 번 정도 있을까 말까. 그 정도 마찰을 피하려고 패키지 분리를 만드는 건 손해다.
Confluence의 검색 언어는 CQL(Confluence Query Language)이다. JQL의 사촌. 같은 회사가 만들었으니 문법도 비슷하다 — space = DEV AND type = page AND lastModified >= "2025-04-01" 같은 식.
search_pages 도구는 CQL을 그대로 받아 패스스루한다. 가공 안 한다. 이게 의도적이다.
Claude는 CQL을 안다. 학습 데이터에 충분히 있다. "지난 주 DEV 스페이스에 만들어진 RFC 페이지 찾아줘"라고 사용자가 말하면, Claude가 알아서 CQL로 번역한다:
space = DEV AND type = page AND title ~ "RFC" AND created >= now("-1w")
내 MCP가 자연어를 CQL로 번역하는 자체 레이어를 가질 이유가 없다. 이 번역은 Claude가 더 잘하고, 내가 끼어들면 망가진다. 내가 자체 DSL을 만들어 봤자 Claude가 그걸 다시 배우게 만드는 비용이 추가될 뿐.
이 결정이 일반화되면 — MCP 도구는 "사용자 의도 → 외부 시스템 호출"을 직접 노출할 수 있을 때 그렇게 하는 게 좋다. 중간에 단순화 레이어를 두면 표현력만 잃는다. CQL/JQL/SQL/JMESPath처럼 잘 알려진 DSL을 그대로 받는 게 답이다.
get_my_recent_work나 get_recent_pages 같은 편의 도구는 별개다 — 자주 쓰는 패턴을 한 번에 부르는 단축. 하지만 일반 검색은 CQL 패스스루.
confluence-mcp의 confluence-client.ts를 열어보면 base URL 두 개를 섞어 쓴다:
// v1: /wiki/rest/api/...
searchPages(cql, maxResults) // /wiki/rest/api/search
listSpaces(limit) // /wiki/rest/api/space
// v2: /wiki/api/v2/...
getPage(pageId) // /wiki/api/v2/pages/{id}
getSpacePages(spaceId, limit) // /wiki/api/v2/spaces/{id}/pages
getChildPages(pageId, limit) // /wiki/api/v2/pages/{id}/children
기능마다 어느 쪽이 더 안정적이고 풍부한지가 달라서 그렇다. Atlassian이 v2 API를 점진적으로 출시하면서 일부 기능은 v2가 훨씬 깔끔하게 정리됐는데, 일부 기능(특히 검색)은 v1만 지원되거나 v1이 더 풍부한 결과를 준다. v2는 페이지 트리 탐색 같은 신규 영역에서 강하고, v1은 검색·전체 목록 같은 레거시 영역에서 안정적이다.
이건 jira-mcp의 "두 개 REST 엔드포인트"(3편)와 비슷한 상황 같지만 사실 다르다:
/rest/api/3, /rest/agile/1.0)는 도메인이 다르다. Core API와 Agile API는 별개 제품이었다가 합쳐진 것./wiki/rest/api, /wiki/api/v2)는 같은 도메인의 다른 버전이다. 점진적 마이그레이션의 흔적.후자가 더 이상하다. 같은 페이지를 v1으로도 v2로도 가져올 수 있는데, 응답 스키마가 다르다. 결과 형태를 통일하느라 클라이언트 안에서 한 번 더 정규화한다 — Claude에게 노출되는 형태는 어느 버전에서 왔든 같다.
3편에서 했던 것과 같은 결론으로 다시 돌아온다 — 버전 차이는 클라이언트 아래에 가둔다. MCP 도구 핸들러도, Claude도, "v1이냐 v2냐"를 모른다. 위 레이어는 그냥 getPage(pageId)를 부른다.
이게 사용자 입장에서 제일 짜증나는 함정이다.
Confluence 사용자는 URL에서 본 스페이스 코드를 외우고 있다. https://xxx.atlassian.net/wiki/spaces/DEV/...에서 DEV 같은 짧은 대문자 키. 이건 사람이 외우라고 만든 식별자다.
근데 v2 API는 숫자 ID만 받는다. DEV 같은 키는 안 받는다. 받으면 400.
GET /wiki/api/v2/spaces/DEV/pages → 400 Bad Request
GET /wiki/api/v2/spaces/13107201/pages → 200 OK
사용자는 "DEV 스페이스 페이지 보여줘"라고 말하지 13107201를 말하지 않는다. Claude도 마찬가지. 이 간극을 누가 메우나? 내 MCP가 메운다.
async getSpacePages(spaceIdentifier: string, limit: number) {
// 숫자면 그대로, 알파벳이면 키로 보고 ID 해석
const spaceId = /^\d+$/.test(spaceIdentifier)
? spaceIdentifier
: await this.resolveSpaceKey(spaceIdentifier);
return this.request(`/wiki/api/v2/spaces/${spaceId}/pages?limit=${limit}`);
}
private async resolveSpaceKey(key: string): Promise<string> {
// v1 API로 키→ID 해석. v2는 키 검색을 안 받음.
const space = await this.requestV1(`/wiki/rest/api/space/${key}`);
return space.id;
}
호출 한 번이 더 든다. 캐싱하면 더 줄일 수 있는데, 스페이스가 추가/삭제되는 빈도를 생각하면 매번 해석해도 큰 비용은 아니라 단순함을 택했다. 한 세션에서 같은 키를 여러 번 쓰면 그때 가서 메모리 캐시를 붙일 생각이었는데, 1년 가까이 써본 결과 그 단계는 안 와도 됐다.
이 작은 자동 해석 덕분에 사용자 경험이 결정적으로 좋아진다. "사용자가 외운 형태"와 "API가 받는 형태"가 다를 때, 그 변환을 내가 품지 않으면 사용자가 매번 ID를 찾아야 한다. 3편의 ADF 왕복도 같은 패러다임이고, 이게 내 안에서 점점 굳어진 원칙 — API의 불편함을 사용자에게 떠넘기지 않는다.
Jira는 ADF, Bitbucket은 마크다운, Confluence는…? Storage Format이다. HTML의 변형 같은 것. <p>, <ul>, <ac:structured-macro> 같은 태그가 섞여 있다.
다행히 v2 API는 응답에 평문 표현도 같이 줄 때가 많다(representation: 'plain' 옵션). 평문이 가능하면 평문으로 받고, Storage Format으로만 오면 태그 스트립으로 처리한다. ADF만큼 짜증나지는 않았지만, 세 시스템이 각자 다른 리치 텍스트 포맷을 쓴다는 사실 자체가 한 번 더 확인됐다. Atlassian 안에서도 통일이 안 돼 있다.
const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
this.headers = {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
3편에서 "세 번 복붙은 그냥 복붙이 낫다"고 한 그 코드. 여기가 세 번째다. CLI도 동일 패턴:
confluence-mcp setup # URL / 이메일 / API 토큰
confluence-mcp register
confluence-mcp status
등록:
claude mcp add \
--transport stdio \
--env CONFLUENCE_BASE_URL=https://xxx.atlassian.net \
--env CONFLUENCE_EMAIL=... \
--env CONFLUENCE_API_TOKEN=... \
--scope user \
confluence -- \
node /Users/<user>/.claude/mcp-servers/confluence-mcp/dist/index.js
mcp__confluence__search_pages, mcp__confluence__get_page 등으로 노출.
이 두 MCP를 같이 등록해두면 진짜 좋은 건 따로 있다. Claude가 둘을 자연스럽게 조합한다.
"PROJ-247 티켓 관련된 RFC 문서 찾아줘"라고 하면:
1. mcp__jira__get_ticket으로 PROJ-247 가져옴
2. 티켓 제목/설명에서 핵심 키워드 추출
3. mcp__confluence__search_pages로 CQL 검색
4. 후보 몇 개 보고 가장 관련 있는 것 골라서 mcp__confluence__get_page
내가 두 호출을 명시적으로 시키지 않아도 Claude가 알아서 한다. MCP 두 개를 나란히 두니까 1+1=3이 되는 순간이었다. 이게 단일 MCP에서는 안 보이는 가치다.
확장하면 — MCP는 단독으로 평가하면 안 되고, 같이 떠 있는 다른 MCP들과의 조합에서 평가해야 한다. 이 감각이 5번째 MCP까지 오는 동안 점점 강해졌다.
confluence-mcp 자체는 jira-mcp의 기술적 변형이다. 새로운 패턴을 발견한 게 적다. 대신 "두 번째 만들 때 무엇을 안 하는가" 에 대한 감각이 생겼다:
그리고 나도 모르게 깨달은 것 — "좋은 MCP는 다른 MCP와 조합될 때 더 빛난다." Jira+Confluence 콤보가 이걸 처음 보여줬다.