티켓에는 "AI 어시스턴트에서 환불 호출 가능하게 만들기"라고 적혀 있습니다. 귀하의 환불 서비스는 이미 두 가지 방법을 가지고 있습니다. 지원 직원은 POST /orders/{id}/refund를 사용하고, 당직 엔지니어는 터미널에서 shop refund A-100 --cents 4999를 실행합니다. 이제 코딩 에이전트나 채팅 클라이언트가 세 번째 방법을 원하며, 이는 Model Context Protocol을 사용합니다.
여기에는 두 가지 방법이 있습니다. 첫 번째 방법은 누군가가 도메인 구조체에 jsonschema 태그를 추가하고, 메타데이터 목적으로 서비스 계층으로 *mcp.CallToolRequest를 전달하며, 환불 규칙이 SDK를 가져오도록 하는 것입니다. 다른 방법은 HTTP 핸들러 옆에 새로운 패키지를 작성하는 것이며, 이때 git diff --stat을 실행하면 internal/domain과 internal/app 아래에서 변경된 프로덕션 코드가 0줄임을 보여줍니다.
이 게시물에서는 공식 MCP Go SDK를 사용하여 두 번째 방법을 구현합니다. 현재 작성하는 시점 기준으로, 이 SDK는 v1.8.0 버전이며, 내부 패키지에서 이를 가져오려고 하면 실패하는 CI 검사를 포함합니다.
이미 가지고 있는 서비스
여기가 도메인입니다. 환불이 무엇인지, 그리고 언제 허용되는지를 알고 있습니다. errors만 가져올 뿐 다른 것은 없습니다.
// internal/domain/order.go
package domain
...
애플리케이션 계층은 두 개의 포트를 소유합니다. Orders는 드라이브된(driven) 포트로, 스토리지가 유스 케이스에 필요한 부분입니다. Refunder는 구동하는(driving) 포트이며, 모든 프론트 도어에서 호출하는 곳입니다.
// internal/app/refund.go
package app
...
서비스는 이를 구현하는 유일한 부분입니다:
// internal/app/refund.go (계속)
type RefundService struct{ Orders Orders }
...
HTTP 어댑터는 JSON 본문을 디코딩하고, app.RefundOrder를 구성하며, Refund를 호출하고, 도메인 오류를 상태 코드로 변환합니다. 이 변환 과정이 보여줄 가치가 있는 유일한 부분인데, 왜냐하면 MCP 어댑터도 자체 버전의 변환 과정이 필요하기 때문입니다:
// internal/adapter/httpapi/status.go
package httpapi
...
CLI 어댑터는 플래그와 종료 코드를 사용하여 동일한 작업을 수행합니다. 두 개의 어댑터, 하나의 포트가 존재하지만, 어느 쪽도 internal/domain에서 직접적으로 보이지 않습니다.
세 번째 어댑터: MCP 도구 핸들러
MCP 서버는 도구를 노출합니다. 클라이언트가 이를 나열하고, 모델이 하나를 선택하며, 클라이언트는 사양의 tools 섹션에 설명된 대로 JSON 인수를 포함하여 tools/call을 전송합니다. Go SDK에서는 mcp.AddTool이 타입 지정된 핸들러를 등록합니다. 패키지 문서에 따르면 입력 스키마는 In 타입에서 추론되고, 설명은 jsonschema 구조체 태그에서 오며, 핸들러가 실행되기 전에 입력이 검증됩니다.
이러한 구조체 태그들은 와이어 형식(wire format)이므로 어댑터에 속합니다. 도메인 Order에는 json 태그가 없으며, 따라서 jsonschema 태그도 가지지 않습니다. 어댑터는 자체 입력 및 출력 타입을 정의하고 필드를 복사합니다:
// internal/adapter/mcpserver/server.go
package mcpserver
...
핸들러는 번역기 그 이상도 이하도 아닙니다:
// internal/adapter/mcpserver/server.go (계속)
func refund(r app.Refunder) refundHandler {
return func(
...
New가 무엇을 받는지 살펴보세요: app.Refunder, HTTP 핸들러가 받는 것과 동일한 인터페이스입니다. 이 핸들러는 *mcp.CallToolRequest를 무시합니다. 만약 호출자의 클라이언트 정보와 같이 그로부터 필요한 것이 있다면, 여기서 읽어와 일반 값을 명령에 전달하세요. 요청 타입 자체를 내부로 전달하지 마십시오.
타입이 지정된 out과 함께 nil 결과를 반환하는 것은 괜찮습니다. SDK 소스에서, nil 결과는 빈 값으로 대체되고, 출력은 StructuredContent로 마샬링되며, JSON 텍스트 복사본이 Content에 들어갑니다. 이는 구조화된 결과도 구형 클라이언트용으로 텍스트 형태로 전송된다는 사양의 권고 사항과 일치합니다.
사양은 두 가지 종류의 실패를 정의합니다. 프로토콜 오류는 알 수 없는 도구, 잘못된 요청 및 서버 결함에 대한 JSON-RPC 오류입니다. 도구 실행 오류는 isError: true와 함께 일반 결과로 반환되며, "이미 환불됨(already refunded)"과 같은 비즈니스 로직 오류를 포함합니다. 사양은 클라이언트가 도구 실행 오류를 모델에 전달하여 자체 수정할 수 있도록 해야 한다고 명시합니다.
이는 HTTP의 409와 500 사이의 분리와 깔끔하게 매핑됩니다. "주문이 이미 환불되었습니다"는 모델이 조치할 수 있는 정보입니다: 중지하거나, 사용자에게 알리는 것 등. 데이터베이스 타임아웃은 그렇지 않으며, 그 텍스트에는 모델의 컨텍스트에 포함하고 싶지 않은 호스트 이름이나 쿼리가 포함될 수 있습니다.
SDK가 내부적인 처리를 담당합니다. ToolHandlerFor가 일반 Go 오류를 반환할 때, CallToolResult 문서에 따르면 IsError가 설정되고 오류 문자열이 텍스트로 Content에 들어갑니다. 프로토콜 오류로 전송되는 것은 오직 *jsonrpc.Error뿐입니다( 서버 코드 참조). 따라서 어댑터의 역할은 모델이 읽을 수 있도록 허용하는 오류 텍스트가 무엇인지 결정하는 것입니다:
// internal/adapter/mcpserver/errors.go
package mcpserver
...
log.Printf는 의도된 것입니다. stdio를 통해 전송할 때, 사양(spec)에 따르면 서버는 유효한 MCP 메시지가 아닌 내용을 stdout에 절대 작성해서는 안 되며, stderr에는 기록할 수 있습니다. Go의 기본 로거는 standard error에 작성하므로 안전합니다. 어댑터에서 실수로 fmt.Println을 사용하는 것은 그렇지 않습니다. 메시지 스트림을 손상시키기 때문입니다.
이 바이너리는 몇 줄의 연결 코드일 뿐입니다. 여기서 인메모리 스토어(in-memory store)가 실제 Postgres 어댑터 역할을 대신합니다:
// cmd/shop-mcp/main.go
package main
...
Claude Code에서 이를 시도하려면, 빌드하고 claude mcp add를 사용하여 로컬 stdio 서버로 등록하세요:
go get github.com/modelcontextprotocol/[email protected]
go build -o bin/shop-mcp ./cmd/shop-mcp
claude mcp add --transport stdio shop -- "$PWD/bin/shop-mcp"
도메인이 깨끗하게 유지되었음을 증명하기
"도메인을 건드리지 않았다"는 주장은 주장일 뿐입니다. 이를 검사로 만드세요. go list -deps는 패키지와 그것이 가져오는 모든 것을 순회하며, go 명령어 문서에서 설명합니다. 내부 패키지들에 대해 실행하고 거기에 있으면 안 되는 것이 있는지 grep으로 확인하세요:
# 모듈 루트에서; go list가 실패하거나 무언가 일치하면 실패함
deps=$(go list -deps ./internal/domain ./internal/app) &&
! grep -E 'modelcontextprotocol|/adapter/' <<<"$deps"
go.mod는 모듈이 이제 SDK를 요구하기 때문에 변경되었지만, 도메인의 의존성 목록은 그렇지 않았습니다. 이것이 이 검사가 강제하는 차이점입니다.
더 엄격한 버전은 테스트로 도메인 패키지에 존재합니다. go list -json은 모든 패키지에 Standard 필드를 보고하며, 표준 라이브러리의 경우 참(true)입니다. 따라서 이 테스트는 도메인이 자신 외의 다른 것을 의존할 때 실패합니다:
// internal/domain/imports_test.go
package domain_test
...
.가 작동하는 이유는 go test가 패키지의 소스 디렉토리 내부에서 각 테스트 바이너리를 실행하기 때문입니다. 그리고 이 명령어는 -test 플래그가 없기 때문에, go list는 테스트 바이너리 대신 패키지 자체를 보고하므로, 테스트 자체의 임포트(os/exec, encoding/json)는 검사 대상에 포함되지 않습니다.
가짜(fake)를 통해 사용 사례를 테스트하고, 그 다음 메모리 내 어댑터를 테스트하기
가짜(fake)는 주문을 맵에 유지하는 구동형 어댑터입니다. 이는 app.Orders를 구현하며 도메인의 찾지 못함 오류(not-found error)를 반환합니다:
// internal/adapter/memstore/orders.go
package memstore
...
사용 사례 테스트는 두 번째 환불이 첫 번째에 의존하기 때문에, 하나의 주문에 대해 순차적으로 단계를 실행합니다:
// internal/app/refund_test.go
package app_test
...
여기에 MCP 관련 코드는 전혀 없습니다. 이 테스트는 세 번째 진입점이 존재하기 전에도 참이었고, 그 이후에도 여전히 참입니다.
어댑터는 자체적인 테스트를 가지며, SDK의 메모리 내 트랜스포트를 사용하므로 프로세스가 생성되지 않습니다. 패키지 예제는 서버를 NewInMemoryTransports의 한쪽 끝에 연결하고 클라이언트를 다른 쪽 끝에 연결합니다. 에이전트에게 중요한 단언(assertion)은, 누락된 주문이 프로토콜 실패가 아니라 IsError가 설정된 툴 결과로 반환된다는 점입니다.
// internal/adapter/mcpserver/server_test.go
package mcpserver_test
...
diff가 어떻게 생겼는지
파일 수를 세어보세요. 새로 추가된 파일: internal/adapter/mcpserver/server.go, errors.go, server_test.go, cmd/shop-mcp/main.go, 그리고 테스트 전용 가드인 internal/domain/imports_test.go입니다. 변경된 파일: go.mod와 go.sum. 건드리지 않은 파일: internal/domain과 internal/app 아래의 모든 프로덕션 파일.
이것이 바로 포트와 어댑터가 제공하는 가치입니다. 새로운 프로토콜이 나타났고, 이로 인해 하나의 어댑터를 추가해야 했습니다. 왜냐하면 해당 사용 사례는 HTTP에 대해 아무것도 알지 못하는 인터페이스를 통해 이미 도달 가능했기 때문입니다. 만약 MCP를 추가하는 것이 환불 규칙을 수정한다는 의미라면, 그 규칙은 HTTP 핸들러에 존재했으며, MCP는 단지 이를 가시적으로 만드는 첫 번째 클라이언트일 뿐입니다.
유지할 만한 또 다른 습관이 있습니다. 모델이 읽을 것처럼 도메인 오류 메시지를 작성하세요. 왜냐하면 이제 누군가 그렇게 할 수 있기 때문입니다. '환불액은 1센트에서 지불된 금액 사이여야 합니다'라는 문구는 에이전트에게 수정할 무언가를 제공합니다. 하지만 '잘못된 입력(invalid input)'이라는 메시지는 그렇지 못합니다.
유용했다면
이는 Hexagonal Architecture in Go가 구축되는 방식과 같은 변화입니다. 즉, 포트가 어디에 위치하고 어떤 패키지가 어떤 타입을 소유하는지를 명확히 하여, 새로운 전송 계층(transport)이 재작성(rewrite) 대신 작은 수정(diff)으로 끝날 수 있게 합니다. 만약 귀하의 Go 서비스가 MCP 도어, 큐 컨슈머 또는 gRPC 엔드포인트를 추가할 예정이라면, 이 책은 본 게시물에서 메모리 내 저장소(in-memory store)로 간략하게 다룬 레이아웃과 영속성 측면을 다루고 있습니다.
Thinking in Go — the 2-book series on Go programming and hexagonal architecture