시리즈 3편. 두 번째로 만든
jira-mcp를 파헤친다. 2편의 bitbucket-mcp에서 확립한 4분리 골격을 여기서는 3분리로 줄였다. 왜 줄였는지가 첫 번째 이야기고, Jira가 description/comment를 평문이 아니라 중첩 JSON(ADF)으로 주고받는다는 게 두 번째 이야기고, 왜 Jira에는 REST 엔드포인트가 두 개인지가 세 번째 이야기다.
bitbucket-mcp를 만들고 "어 이거 생각보다 단순하네" 싶어서 바로 다음 타겟을 골랐다. 회사에서 스프린트 스탠드업 전에 "이번 주 내가 뭘 했지?"를 Jira에서 긁어오는 게 항상 성가셨다. JQL을 매번 쓰기도 싫고, 웹 대시보드로 가서 필터링하는 것도 싫었다.
결정적이었던 건 Atlassian API Token을 Bitbucket MCP 만들 때 이미 발급해놨다는 사실이다. 같은 토큰 재사용. 스코프에 Jira까지 포함돼 있으니, 새 토큰을 만들 필요도 없었다.
get_ticket 티켓 상세 (제목/설명/상태/담당자/라벨/코멘트)
search_tickets JQL 패스스루 검색
get_my_tickets 나에게 할당된 티켓
get_sprint_tickets 활성 스프린트 티켓 (보드 지정 or JQL)
get_weekly_summary 주간 작업 요약 (완료/진행중 그룹핑)
add_comment 코멘트 추가 (ADF 자동 래핑)
bitbucket-mcp보다 하나 많은 6개. 그리고 6개 중 5개는 조회고, 1개(add_comment)만 쓰기다. 쓰기 도구가 등장한 게 bitbucket-mcp와의 큰 차이점이다 — 이것 때문에 ADF를 반대 방향으로도 다뤄야 했다.
2편에서 bitbucket-mcp의 4분리 패턴(index / cli / client / formatter)을 자랑했는데, jira-mcp는 3분리다. formatter 파일이 없다. 포매팅이 jira-client.ts 안에 녹아있다. 메서드가 이미 포매팅된 문자열을 반환한다.
왜 줄였나? 분리가 필요 없었기 때문이다.
2편에서 세워둔 기준을 다시 꺼내면: "도메인 지식이 있는 정제 단계가 있느냐." bitbucket-mcp는 있었다 — 전체 코멘트에서 CodeRabbit 것만 골라내고, 마커를 제거하고, 헤드라인을 뽑고, 종류별로 재그룹핑하는 파이프라인. 이게 도메인 지식이다.
jira-mcp는 없다. API가 돌려주는 티켓 객체에서 필드 몇 개 읽어서 정돈된 문자열로 만들면 끝이다. "제목은 이렇게, 상태는 이렇게, 코멘트는 이렇게 나열" — 이건 도메인 지식이 아니라 뷰 템플릿이다. 별도 파일로 뽑을 만큼 복잡하지 않다.
3분리에서 4분리로 가는 선은 "이 정제 로직을 단독으로 테스트하고 싶은가?"로 나는 판단했다. CodeRabbit의 cleanBody는 단독 테스트하고 싶었다 — 마커 형식이 바뀔 여지가 있고, 정규식이 깨질 여지도 있으니. Jira의 formatIssue는 단독 테스트 해봐야 의미가 크지 않다. 필드 이름 바꾸거나 줄바꿈 조정하는 수준이니.
작은 결정이지만 여기서 "분리는 의도적이어야 한다"는 감각이 붙었다. 바로 다음에 만든 confluence-mcp도 3분리로 갔고, postman-mcp는 4분리로 다시 돌아왔다(이유는 5편에서).
이게 jira-mcp의 메인 이벤트다.
Jira Cloud REST API v3은 티켓의 description과 코멘트의 body를 평문이 아니라 ADF(Atlassian Document Format) 라는 중첩 JSON 구조로 돌려준다. 예를 들어 "버그 재현 단계 정리했습니다" 같은 한 줄짜리 코멘트가 이렇게 내려온다:
{
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "버그 재현 단계 정리했습니다" }
]
}
]
}
조금 더 긴 글이 되면 content 안에 bulletList, codeBlock, mention, emoji가 중첩되면서 JSON 트리가 금세 깊어진다. 이걸 그대로 Claude에게 넘기면 토큰 낭비도 낭비지만, Claude가 실제 내용을 파악하는 데 필요한 인지 비용이 올라간다. 2편에서 "JSON이 아니라 문자열"이라고 주장한 이유가 여기서도 적용된다.
해결책은 재귀 추출:
const extractTextFromAdf = (node: unknown): string => {
if (!node || typeof node !== 'object') return '';
const n = node as Record<string, unknown>;
if (n.type === 'text') return (n.text as string) || '';
if (Array.isArray(n.content)) {
return (n.content as unknown[]).map(extractTextFromAdf).join('');
}
return '';
};
5줄짜리 함수로 끝난다. type === 'text'인 노드의 text만 모아서 이어붙인다. 구조(단락/리스트/코드블록)는 잃지만 Claude가 이해하는 데 필요한 건 내용이지 구조가 아니다. 필요하면 paragraph 사이에 개행을 넣는 식으로 살짝 살릴 수는 있는데, 경험상 그 정도도 오버엔지니어링이었다. 평문으로 이어붙이는 것만으로 충분했다.
add_comment는 반대 방향이다. 사용자(또는 Claude)가 "이 부분은 다음 스프린트로 넘깁시다" 같은 평문을 주면, Jira API에 보내기 전에 ADF로 감싸야 한다:
body: {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: comment }],
},
],
}
단락 하나에 텍스트 노드 하나. 이게 가장 간단한 유효 ADF 문서다. 마크다운 처리, 멘션, 링크 같은 건 지원 안 한다 — 필요하면 나중에 확장하기로 하고 일단 단순한 케이스만 지원했다. 지금도 이 수준으로 충분하다. Claude가 코멘트 쓸 때 rich formatting 쓸 일이 거의 없더라.
불평하려는 게 아니다. ADF는 구조화된 편집을 위한 포맷이다. 위지윅 에디터에서 "이 문단을 bullet list로 바꿔줘"가 즉시 가능하려면, 서버가 받은 텍스트가 이미 트리 구조여야 한다. 마크다운이 아니라 ADF인 이유는 마크다운이 표현은 되지만 편집하기 어려운 포맷이기 때문이다. Atlassian이 자기 제품군(Jira/Confluence/Bitbucket)에서 통일된 리치 텍스트 에디터를 굴리려니까 이 선택이 됐을 것이다.
문제는 API 소비자 관점에서는 읽기 쓰기 양쪽에 변환 단계가 생긴다는 것. 사람 눈으로는 한 줄짜리 코멘트인데, JSON으로는 3단 중첩. 외부 시스템 붙일 때마다 이 변환을 새로 써야 한다.
교훈 — "API가 사용자가 원하는 형태가 아닐 수도 있다"는 사실을 인정하고, 그 변환을 내 쪽에 품어야 한다. Jira를 바꿀 수 없으니, 내 MCP가 변환기 역할을 맡는다. 위로 Claude에게 보낼 때는 ADF → 평문, 아래로 API에 보낼 때는 평문 → ADF. 왕복.
Jira 클라이언트 클래스를 열어 보면 이상한 게 하나 있다:
export class JiraClient {
private async request<T>(path: string): Promise<T> {
return fetch(`${baseUrl}/rest/api/3${path}`, { headers: this.headers })
.then(/* ... */);
}
private async agileRequest<T>(path: string): Promise<T> {
return fetch(`${baseUrl}/rest/agile/1.0${path}`, { headers: this.headers })
.then(/* ... */);
}
}
엔드포인트 베이스가 두 개다. 티켓·검색·코멘트는 /rest/api/3, 스프린트·보드는 /rest/agile/1.0. 인증 헤더는 같지만 경로 prefix가 다르다.
왜 둘인가? Jira Software(옛 Jira Agile, 그 전엔 GreenHopper라는 별개 플러그인)의 흔적이다. Atlassian이 애자일 보드 기능을 별도 제품으로 시작해서 나중에 Jira 본체에 통합했는데, 그때 생긴 API 네임스페이스가 지금까지 남아 있다. /rest/agile/1.0은 스프린트, 보드, 백로그, 에픽 관련 API를 담당하고, 그 외 모든 것(이슈·프로젝트·유저·JQL)은 /rest/api/3.
처음엔 이게 꽤 당황스러웠다. "스프린트에 속한 이슈 목록"을 원할 때 — 이건 /rest/agile/1.0/sprint/{id}/issue로 가야 할까, 아니면 /rest/api/3/search?jql=sprint={id}로 가야 할까? 답은 둘 다 된다. 어느 쪽이 더 안정적이고 더 풍부한 정보를 주느냐만 선택의 문제.
내가 선택한 절충은 이렇다:
get_sprint_tickets는 보드 ID가 주어지면 Agile API로 가고, 안 주어지면 JQL(sprint in openSprints())로 Core API에 간다이 분기 규칙을 jira-client.ts 안에 캡슐화하고 나니 위 레이어에서는 이 복잡함을 몰라도 된다. MCP 도구 핸들러는 "스프린트 티켓 가져와"만 알면 된다. "두 베이스가 있다"는 사실은 클라이언트 아래로 숨긴다 — 이게 이 파일이 존재하는 이유다.
도구 6개 중 가장 자랑하고 싶은 건 get_weekly_summary다. 내부 구현은 단순하다:
week_offset(기본 0)로 대상 주의 월~일을 계산assignee = currentUser() AND updated >= 2025-04-14 AND updated <= 2025-04-20 같은 JQL 조립스탠드업 전에 Claude Code에게 "지난주 요약해줘"만 말하면 끝. 5초 정도 걸리고, 토큰 몇 천 개 쓴다.
이게 왜 자랑스럽냐면 — "LLM이 들어오기 전이었으면 만들지 않았을 도구"라서다. 기존 Jira 대시보드 위젯으로도 비슷한 건 볼 수 있다. 하지만 "위젯을 설정하는 수고" vs "한 문장으로 부르는 편함"은 체감 차이가 크다. LLM이 맥락을 쥐고 있으니까 "지난주 말고 지지난주 것도 비교해줘" 같은 자연스러운 follow-up이 된다.
이 도구를 만들고 나서부터 MCP를 "기존 기능을 래핑하는 것"이 아니라 "LLM이 있기에 생기는 새로운 인터페이스"로 보기 시작했다. 3편에서 배운 가장 큰 것.
2편에서 Bitbucket이 Bearer를 안 받는다고 두 시간 날렸다고 썼다. Jira는 정상적이다. Atlassian API Token을 Bearer로도 받고 Basic으로도 받는다. 다만 일관성을 위해 Basic으로 통일했다. 같은 jira-mcp/bitbucket-mcp/confluence-mcp 세 코드베이스에서 인증 코드가 똑같아 보이길 원했으니까.
const auth = Buffer.from(`${email}:${token}`).toString('base64');
this.headers = {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
bitbucket-mcp, jira-mcp, confluence-mcp 세 파일이 이 세 줄을 공유한다. 복붙이지만 추상화할 만큼의 가치는 없다 — 세 번 반복되는 정도는 그냥 복붙이 낫다.
2편과 동일한 패턴이라 짧게만:
jira-mcp setup # URL(https://xxx.atlassian.net) / 이메일 / API 토큰
jira-mcp register # claude mcp add 실행
jira-mcp status
등록 명령:
claude mcp add \
--transport stdio \
--env JIRA_BASE_URL=https://xxx.atlassian.net \
--env JIRA_EMAIL=... \
--env JIRA_API_TOKEN=... \
--scope user \
jira -- \
node /Users/<user>/.claude/mcp-servers/jira-mcp/dist/index.js
등록 후 mcp__jira__get_ticket, mcp__jira__search_tickets, mcp__jira__get_weekly_summary 등으로 노출.
jira-mcp에서 배운 건 두 가지로 압축된다:
jira-mcp는 "복잡해지는 걸 막으려는 자제력"이 처음 생긴 프로젝트였다. 4-파일 구조가 있다고 해서 모든 MCP에 강요할 이유가 없다는 것, API의 이상함을 클라이언트 아래로 숨길 책임은 내게 있다는 것. 이 두 감각이 그 다음 세 MCP의 밑돌이 됐다.
4편 — confluence-mcp: Jira의 쌍둥이. 3-파일 구조 그대로 가져오되, JQL 대신 CQL(Confluence Query Language)을 쓰고, 본문 포맷은 ADF가 아닌 Storage Format(HTML 변형)이다.