LLM에게 이슈 트래커를 맡기기 — MCP 도구 표면 설계
팀 이슈 트래커를 MCP 도구로 노출하며 세 번 뒤집은 설계 — LLM에게 UUID를 시키지 않고, 가드레일을 설명문에 심고, 겹치는 알림 책임을 한 곳으로 모은 기록.
개발 흐름은 편집기와 터미널에 있는데, 이슈 트래커는 브라우저에 있다. LLM 에이전트와 일하기 시작하면서 이 간극이 더 거슬리기 시작했다 — 코드를 고친 에이전트가 이슈도 만들고 닫아 주면 될 일 아닌가. 그래서 팀이 쓰는 self-hosted 이슈 트래커인 Plane을 MCP 도구로 노출하는 서버를 만들었다. 만들고 보니 어려운 것은 API 호출이 아니라 도구 표면 설계였고, 두 주 동안 설계를 세 번 뒤집었다.
한 줄 요약: 이슈·문서 페이지 관리를 MCP 도구로 노출하며 세 가지를 배웠다 — LLM에게 UUID를 시키지 말 것, 파괴적 도구의 가드레일은 설명문에 있다는 것, 그리고 알림처럼 겹칠 수 있는 책임은 한 곳에만 둘 것.
배경
MCP(Model Context Protocol)는 도구를 표준 방식으로 LLM 에이전트에 노출하는 프로토콜이다. 도구 이름·설명·입력 스키마를 선언해 두면 에이전트가 대화 중에 골라 호출한다. 우리 팀은 이슈 트래커로 self-hosted Plane을 쓰고 있었고, 목표는 "이 버그 이슈로 만들어서 나한테 할당해줘" 한 문장이 곧 호출이 되는 것이었다. 에이전트가 한 일을 팀도 알아야 하므로, 변경 사항은 사내 통합 메시징을 거쳐 팀 메신저로 알림이 가게 했다.
문제를 느낀 계기
트래커의 REST API를 얇게 감싸면 끝인 줄 알았다. 써 보니 아니었다.
- API의 사용자와 도구의 사용자는 다르다. REST API는 모든 것을 UUID로 받는다. 프로젝트도 이슈도 담당자도 상태도. 사람은 "PROJ-12", 닉네임, "Done"으로 부른다 — 그리고 LLM도 사람 쪽이다. UUID를 그대로 노출하면 모델은 id를 찾아 헤매거나, 최악의 경우 그럴듯한 UUID를 지어낸다. 그것도 아주 자신 있게.
- 알림이 두 번 울렸다. 처음엔 도구가 변경할 때마다 팀 메신저로 직접 알림을 쐈다. 그런데 트래커 자체의 웹훅 알림이 붙자 같은 변경이 두 번 울리기 시작했다 — 알림 책임이 두 곳에 있었던 것이다.
- 위험한 도구가 너무 매끄럽다. 삭제 API는 다른 API와 똑같이 생겼지만 결과는 회수 불가다. 문서 페이지는 더 위험했다 — 실시간 공동 편집 문서라서, API로 본문을 덮으면 열려 있는 편집 탭이 옛 본문을 되밀어 덮는 병합 사고가 난다.
- 공개 API의 구멍. 이슈 목록에 필터 파라미터가 없었고, 문서 페이지는 공개 API에 아예 노출되지 않았다.
제약
- 도구의 사용자는 사람 표기만 안다 — 이슈는
IDENT-번호, 멤버는 닉네임, 상태는 이름. - 도구 설명문이 모델이 읽는 유일한 매뉴얼이다.
- 알림은 반드시 가야 하지만 두 번 가면 안 된다.
- 팀원 각자의 로컬에 설치되는 도구라, 배포와 업데이트 경로가 단순해야 한다.
선택지
1. REST API의 얇은 프록시 장점: 구현이 빠르다. 단점: UUID 세계를 그대로 노출한다.
2. "이슈 관리해줘" 하나짜리 만능 도구 장점: 표면이 작다. 단점: 무엇이 가능한지 스키마가 말해 주지 않아 모델의 추측에 모든 것을 맡긴다.
3. 사람 표기 기반의 좁은 도구들 + 해석 계층 장점: 도구 하나가 의도 하나, 스키마와 설명이 곧 사용법. 단점: 표기→식별자 해석을 서버가 진다.
3이다. 해석 계층이 이 서버의 존재 이유가 됐다.
결정
사람 표기를 서버가 해석한다. 프로젝트는 별칭으로, 이슈는 PROJ-12 표기로 받는다.
// 사람 표기 "PROJ-12" → 내부 식별자. LLM 에게 UUID 를 시키지 않는다.
export async function resolveIssueRef(ref: string) {
const m = ref.match(/^([A-Za-z]+)-(\d+)$/);
if (!m) throw new Error(`이슈 표기 형식 오류: ${ref} (예: PROJ-12)`);
const project = resolveProject(m[1]); // 별칭 → 프로젝트
const issueId = await findIssueIdBySequence(project.id, Number(m[2]));
if (!issueId) throw new Error(`이슈 없음: ${ref}`);
return { projectId: project.id, issueId };
}
담당자는 닉네임 부분일치로, 상태는 분류(group) 또는 이름으로 찾는다. 트래커의 사용자 id와 메신저 계정도 서버가 이어 붙여, 알림에 실릴 멘션까지 사람 표기에서 출발하게 했다. 그리고 원칙 하나 — 애매하면 조용히 고르지 않고, 후보를 들려주며 실패한다.
// 애매한 매칭은 조용히 고르지 않는다 — 후보를 나열하고 실패한다.
export function findStateByName(states: State[], name: string): State {
const key = name.toLowerCase();
const matches = states.filter(s => s.name.toLowerCase().includes(key));
if (matches.length === 0) throw new Error(`state "${name}" 매칭 없음`);
if (matches.length > 1)
throw new Error(`"${name}" 다수 매칭: ${matches.map(s => s.name).join(", ")} — 정확히 지정해주세요`);
return matches[0];
}
알림 책임은 한 곳으로. 도구의 직접 발송을 전부 걷어내고, 알림은 트래커 웹훅 → 통합 메시징 한 경로만 남겼다. 도구는 변경만 하고, 변경을 알리는 것은 웹훅의 일이다. 겹칠 수 있는 책임을 두 곳에 두면 언젠가 반드시 두 번 울린다.
파괴적 도구에는 마찰을 심는다. 삭제 도구는 confirm=true를 필수로 받고 설명문에 "영구 삭제, 회수 불가"를 박았다. 문서 페이지 수정 도구에는 병합 사고의 메커니즘 자체를 설명문에 적었다 — 열려 있는 편집 탭이 옛 본문을 되밀 수 있으니 닫힘을 확인하고 쓰라고. 도구 설명문은 장식이 아니라 모델이 읽는 가드레일이다.
배포는 단순한 쪽으로 되돌렸다. 팀원 로컬 설치라는 조건 때문에 실행 시 백그라운드 자가 업데이트를 넣었는데, 이틀 만에 걷어냈다. 플랫폼마다 다른 실패 방식에, 지금 어느 버전이 도는지 사용자가 알 수 없는 상태가 생겼다 — 편의보다 예측 가능성이 비쌌다. 표준 패키지 실행 방식으로 되돌리고, 업데이트는 사용자의 명시적 행동으로 남겼다.
API의 구멍은 도구가 메우되, 숨기지 않는다. 목록 필터는 커서를 따라 전량을 받아 서버 쪽에서 거른다 — 그 사실을 도구 설명에 적어 뒀다. 문서 페이지는 공개 API에 없어서, 트래커 서버에 공개 API를 여는 패치를 따로 만들어 적용한 뒤에야 도구를 붙일 수 있었다.
결과와 한계
- 얻은 것: 이슈 생성, 상태 전이, 담당자 지정, 라벨, 에픽, 문서 페이지까지 대화 안에서 끝난다. 변경은 웹훅 한 경로로 팀에 알려진다.
- 잃은 것: 필터를 서버가 지는 전량 수집 비용, 프로젝트 별칭 맵의 수동 관리, 그리고 트래커 업그레이드 때마다 따라가야 하는 패치 유지보수.
- 되돌릴 조건: 이슈 규모가 전량 수집이 아플 수준이 되면 트래커 쪽 검색 API 확장을 먼저 고려한다.
만들고 나서 남은 감상은 하나다. 도구 설계는 API 설계보다 사용자 모델링에 가깝다. 사용자가 무엇을 아는지 — 사람 표기 — 무엇을 모르는지 — UUID — 그리고 어디서 실수하는지 — 애매한 매칭, 파괴적 호출, 겹치는 알림 — 를 정하고 표면을 거기에 맞추는 일이다. 사용자가 LLM이라는 점만 다를 뿐이다.