표준 라이브러리 Go를 사용하여 CLI를 MCP 도구로 노출하기
요약
Go 표준 라이브러리만을 사용하여 CLI를 MCP(Model Context Protocol) 서버로 구현하는 방법을 다룹니다. 외부 의존성 없이 stdio를 통해 JSON-RPC 2.0 기반의 도구를 노출하여 에이전트가 코드 리뷰를 수행할 수 있게 합니다.
핵심 포인트
- Go 표준 라이브러리(encoding/json, bufio)만 사용하여 의존성 제로 구현
- stdio 기반의 JSON-RPC 2.0 프로토콜을 통한 MCP 서버 구축
- 에이전트가 도구 호출(tool call)로 코드 리뷰를 수행할 수 있는 환경 제공
- 메모리 고갈 방지를 위한 maxMessageBytes(16 MiB) 제한 및 플러시 처리
commitbrief mcp는 리뷰 파이프라인을 Model Context Protocol (MCP) 서버로 전환하여, 에이전트가 도구 호출 (tool call)로서 코드 리뷰를 실행할 수 있게 합니다. 이는 일반적으로 에이전트가 방금 작성한 코드를 제출하기 전 수행하는 셀프 체크 (self-check) 역할을 합니다. MCP 지원을 추가하는 것은 보통 SDK를 가져오는 것을 의미합니다. CommitBrief의 서버는 encoding/json과 bufio만 사용하며, 단 두 개의 파일로 구성되어 새로운 의존성(dependency)이 전혀 없습니다. 이는 stdio MCP 서버가 실제로 필요로 하는 인터페이스가 충분히 작아서, 직접 구현하는 비용이 의존성을 추가하는 것보다 적기 때문입니다.
요약 (TL;DR)
commitbrief mcp는 줄 단위로 구분된 stdio를 통해 JSON-RPC 2.0을 사용합니다. 공표된 프로토콜 버전은2024-11-05입니다.- 서버는 표준 라이브러리(standard-library)만 사용합니다: 엔벨로프(envelopes)를 위한
encoding/json, 프레이밍(framing)을 위한bufio. MCP SDK도 없고, 라이선스 감사를 위한 새로운 의존성도 없습니다. review라는 하나의 도구를 노출하며, 이는commitbrief --json과 정확히 동일한 파이프라인을 실행하고 schema-v1 결과물과 짧은 텍스트 요약을 반환합니다.- 한계점. stdio 전송 방식만 지원하며, 리뷰에는 여전히 실제 프로바이더(provider) 호출 비용이 발생합니다. 또한 동일한 제로스 리뷰어(zeroth reviewer)이며, 더 똑똑해진 것이 아니라 에이전트가 호출할 수 있게 된 것뿐입니다.
전송 방식은 한 줄과 플러시(flush)입니다
프레이밍(framing)에 대한 모든 결정 사항은 패키지 문서에 있으며, 이는 무언가를 하지 않기로 한 결정입니다:
// 전송 방식은 줄 단위로 구분된 JSON입니다: 각 JSON-RPC 메시지는
// 별도의 줄에 작성되어 플러시(flushed)되는 단일
// 객체입니다 [...] 우리는 선택 사항인 Content-Length 헤더 프레이밍을
// 의도적으로 구현하지 않았습니다 — 줄 형태가...
따라서 읽기 루프는 bufio.Scanner를 사용하며, 한 줄당 하나의 메시지를 처리합니다. 결과 문서가 기본값인 64 KiB를 초과할 수 있으므로 토큰 제한(token cap)을 높였습니다:
func (s *Server) Serve(ctx context.Context, r io.Reader, w io.Writer) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), maxMessageBytes)
...
maxMessageBytes는 16 MiB입니다. 이는 가장 큰 규모의 현실적인 리뷰를 처리하기에 충분하며, 통제 불능의 피어(peer)가 메모리를 고갈시키지 않도록 제한된 수치입니다. 모든 응답은 즉시 작성되고 _플러시(flushed)_됩니다. 왜냐하면 표준 입출력(stdio)은 대화형(interactive)이며, 플러시되지 않은 버퍼는 핸드셰이크(handshake) 과정에서 데드락(deadlock)을 유발할 수 있기 때문입니다.
중요한 메서드들
stdio를 통한 MCP는 몇 가지 메서드가 필요하며, 디스패처(dispatcher)는 switch 문으로 구현됩니다:
switch req.Method {
case "initialize":
return s.handleInitialize(req)
...
initialize는 프로토콜 버전, tools 전용 기능(capabilities) 객체, 그리고 서버 식별 정보로 응답합니다. 알림(Notifications) — notifications/initialized와 같이 id가 없는 모든 것 — 은 부수 효과(side effects)를 위해 처리되며 응답을 보내지 않습니다. 이는 JSON-RPC 명세(spec)에서 요구하는 사항입니다. 예약된 에러 코드(-32700 파싱 에러, -32601 메서드 찾을 수 없음 등)는 명세에 정의된 대로 그대로 사용됩니다.
실패한 리뷰는 콘텐츠이지, 프로토콜 에러가 아닙니다
여기 복사할 가치가 있는 설계 선택이 있습니다. review 도구가 실패할 때 — 스테이징된 변경 사항이 없거나, 비밀 정보 스캔(secret-scan) 가드가 중단되거나, 프로바이더(provider) 타임아웃이 발생하는 경우 — 이는 JSON-RPC 에러가 아닙니다. 이는 결과에 isError 플래그를 포함하는 성공적인 호출입니다:
summary, structured, err := handler(ctx, params.Arguments)
if err != nil {
// 도구 수준의 실패: JSON-RPC가 아닌 IsError를 포함한 콘텐츠로 노출
...
이 차이는 회복 가능한 에이전트와 정지해 버리는 에이전트 사이의 차이를 만듭니다. JSON-RPC 프로토콜 에러는 호출을 해제(tear down)해 버리지만, isError 결과는 모델에게 "스테이징된 변경 사항 없음"과 같이 읽고 행동할 수 있는 실행 가능한 문장을 전달합니다. 프로토콜 에러는 잘못된 형식의 엔벨로프(envelopes)를 위해 예약해 두고, 모델이 학습해야 할 모든 것은 콘텐츠로서 전달됩니다.
도구는 파이프라인의 복사본이 아니라, 그 자체인 파이프라인입니다
도구에 두 번째 엔트리 포인트(entry point)를 연결할 때 유혹에 빠지기 쉬운 것은 더 가벼운 버전을 재구현하는 것입니다. CommitBrief는 그렇게 하지 않습니다. MCP 핸들러는 터미널에서 사용하는 것과 동일한 runReview 함수를 실행합니다. 주석은 그 경계(seam)에 대해 명시적으로 설명하고 있습니다:
// 하류(downstream)의 모든 과정 — diff fetch, 3단계 필터링, 전송 전
// 가드(guard) + 비밀 정보 스캔(secret scan), 토큰/비용 사전 점검(preflight), 캐시, 프로바이더(provider) 호출,
// 불안정한 사전 패스(flaky pre-pass), 그리고 시그널 제어 — 는 터미널에서와 정확히 동일하게 실행됩니다.
...
이는 머신 출력(machine-output) 플래그를 강제하고 렌더링된 문서를 캡처함으로써 이루어집니다:
global = globalFlags{color: "never"}
reviewScope = reviewScopeFlags{}
global.json = true
...
이러한 재사용으로부터 두 가지 결과가 도출됩니다. 첫째, MCP 서버는 외부 스크립트가 사용하는 것과 동일한 잠긴 JSON 스키마 v1을 사용하는 얇은 소비자(thin consumer)입니다. 즉, 파이프라인 내부로 직접 접근하는 대신 렌더링된 출력을 다시 파싱(re-parse)하므로, 에이전트(agent)와 셸 --json 파이프라인은 바이트 단위로 동일한 계약(contract)을 보게 됩니다. 둘째, 호스트(host)는 비대화형(non-interactive)이므로, 만약 전송 전 비밀 정보 스캔(pre-send secret scan) 과정에서 확인 프롬프트가 발생하면 호출은 중단되고 도구 오류(tool error)로 나타납니다. 에이전트는 "네, 그래도 비밀 정보를 전송하겠습니다"라고 클릭할 수 없습니다. 모델이 구동 중일 때도 안전한 기본값(safe default)이 유지됩니다.
도구의 입력 스키마(input schema)는 CLI 플래그인 staged, unstaged, diff, provider, model, fail_on, min_severity, no_flaky를 그대로 반영하며, additionalProperties: false를 포함합니다. 또한 DisallowUnknownFields()를 사용하여 디코딩되므로, 호스트가 fail_on 대신 failon을 보낼 경우 아무 작업도 수행하지 않고 넘어가는 대신 명확한 오류를 받게 됩니다.
이것이 아닌 것
이것은 stdio 전송 방식(transport) 전용입니다. HTTP, SSE, Content-Length 프레이밍(framing)은 지원하지 않으며, 단일 연결을 통해 단일 도구가 순차적으로 제공됩니다. 이는 미완성된 것이 아니라 의도적인 하한선(floor)입니다. stdin의 반대편에는 정확히 하나의 호스트만 존재하며, 리뷰는 블로킹 호출(blocking call)이므로 동시성(concurrency)을 도입해도 얻을 이득이 없으며 오히려 가드 프롬프트(guard prompts)를 복잡하게 만들 뿐입니다.
그리고 리뷰를 에이전트(agent)에게 노출한다고 해서 리뷰의 본질이 변하는 것은 아닙니다. 리뷰는 여전히 토큰(tokens)과 몇 초의 시간이 소요되는 실제 프로바이더(provider) 호출을 수행하며, 명백하지만 놓치기 쉬운 클래스(class) 오류는 잡아내지만 의도 수준(intent-level)의 설계 문제는 놓칩니다. MCP 서버는 코드가 제출되기 전 빠른 자가 점검(self-check)을 수행할 수 있도록, 에이전트 루프(agent loop) 내부에서 '제0차 리뷰어(zeroth reviewer)'를 호출할 수 있게 만듭니다. 이것이 이후에 진행될 인간의 리뷰(human review)를 대체하는 것은 아닙니다.
Repo: github.com/CommitBrief/commitbrief.
Building CommitBrief의 7부. 다음 편: 시그널 제어(signal control) — CommitBrief가 동일한 발견 사항을 두 번 다시 플래그(flagging)하지 않도록 방지하는 베이스라인(baseline) 및 인라인 억제(inline-suppression) 레이어.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기