- Book: Hexagonal Architecture in Go
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
The ticket says: "Make refunds callable from the AI assistant." Your refund service already has two ways in. Support staff hit POST /orders/{id}/refund, and the on-call engineer runs shop refund A-100 --cents 4999 from a terminal. Now a coding agent or a chat client wants a third way in, and it speaks the Model Context Protocol.
There are two ways this goes. In one, somebody adds jsonschema tags to the domain structs, threads an *mcp.CallToolRequest into the service layer "just for the metadata", and the refund rules now import an SDK. In the other, you write one new package next to the HTTP handler, and git diff --stat shows zero lines of production code changed under internal/domain and internal/app.
This post builds the second one with the official MCP Go SDK, at v1.8.0 as I write this, down to a CI check that fails if the inner packages ever import it.
The service you already have
Here is the domain. It knows what a refund is and when one is allowed. It imports errors and nothing else.
// internal/domain/order.go
package domain
import "errors"
var (
ErrOrderNotFound = errors.New("order not found")
ErrAlreadyRefunded = errors.New("order was already refunded")
ErrInvalidAmount = errors.New(
"refund must be between 1 cent and the amount paid")
)
type Order struct {
ID string
PaidCents int64
Refunded bool
}
func (o *Order) Refund(cents int64) error {
if o.Refunded {
return ErrAlreadyRefunded
}
if cents < 1 || cents > o.PaidCents {
return ErrInvalidAmount
}
o.Refunded = true
return nil
}
The application layer owns two ports. Orders is the driven port, the storage the use case needs. Refunder is the driving port, the one every front door calls.
// internal/app/refund.go
package app
import (
"context"
"example.com/shop/internal/domain"
)
// Orders is the driven port: storage the use case needs.
type Orders interface {
Get(ctx context.Context, id string) (domain.Order, error)
Save(ctx context.Context, o domain.Order) error
}
// RefundOrder is the command every driving adapter builds.
type RefundOrder struct {
OrderID string
Cents int64
}
type Refunded struct {
OrderID string
Cents int64
}
// Refunder is the driving port. HTTP, CLI and MCP call this.
type Refunder interface {
Refund(ctx context.Context, c RefundOrder) (Refunded, error)
}
The service is the only thing that implements it:
// internal/app/refund.go (continued)
type RefundService struct{ Orders Orders }
func (s RefundService) Refund(
ctx context.Context, c RefundOrder,
) (Refunded, error) {
o, err := s.Orders.Get(ctx, c.OrderID)
if err != nil {
return Refunded{}, err
}
if err := o.Refund(c.Cents); err != nil {
return Refunded{}, err
}
if err := s.Orders.Save(ctx, o); err != nil {
return Refunded{}, err
}
return Refunded{OrderID: o.ID, Cents: c.Cents}, nil
}
The HTTP adapter decodes a JSON body, builds an app.RefundOrder, calls Refund, and translates domain errors into status codes. The translation is the only part worth showing, because the MCP adapter will need its own version of it:
// internal/adapter/httpapi/status.go
package httpapi
import (
"errors"
"net/http"
"example.com/shop/internal/domain"
)
func statusFor(err error) int {
switch {
case errors.Is(err, domain.ErrOrderNotFound):
return http.StatusNotFound
case errors.Is(err, domain.ErrAlreadyRefunded):
return http.StatusConflict
case errors.Is(err, domain.ErrInvalidAmount):
return http.StatusUnprocessableEntity
}
return http.StatusInternalServerError
}
The CLI adapter does the same thing with flags and exit codes. Two adapters, one port, and neither one is visible from internal/domain.
The third adapter: an MCP tool handler
An MCP server exposes tools. A client lists them, the model picks one, and the client sends tools/call with JSON arguments, as the tools section of the spec describes. In the Go SDK, mcp.AddTool registers a typed handler. The package docs say the input schema is inferred from the In type, descriptions come from jsonschema struct tags, and inputs are validated before your handler runs.
Those struct tags are wire format, so they belong to the adapter. The domain Order has no json tags and it will not get jsonschema tags either. The adapter defines its own input and output types and copies fields across:
// internal/adapter/mcpserver/server.go
package mcpserver
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"example.com/shop/internal/app"
)
type refundIn struct {
OrderID string `json:"order_id" jsonschema:"order to refund"`
Cents int64 `json:"cents" jsonschema:"amount in cents"`
}
type refundOut struct {
OrderID string `json:"order_id"`
Cents int64 `json:"cents"`
}
type refundHandler = mcp.ToolHandlerFor[refundIn, refundOut]
func New(r app.Refunder) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{
Name: "shop", Version: "v0.1.0",
}, nil)
mcp.AddTool(s, &mcp.Tool{
Name: "refund_order",
Description: "Refund a paid order once, " +
"for at most the amount paid.",
}, refund(r))
return s
}
The handler is a translator and nothing more:
// internal/adapter/mcpserver/server.go (continued)
func refund(r app.Refunder) refundHandler {
return func(
ctx context.Context, _ *mcp.CallToolRequest, in refundIn,
) (*mcp.CallToolResult, refundOut, error) {
res, err := r.Refund(ctx, app.RefundOrder{
OrderID: in.OrderID,
Cents: in.Cents,
})
if err != nil {
return nil, refundOut{}, toolError(err)
}
out := refundOut{OrderID: res.OrderID, Cents: res.Cents}
return nil, out, nil
}
}
Look at what New takes: app.Refunder, the same interface the HTTP handler takes. The handler ignores the *mcp.CallToolRequest. If you ever need something from it, such as the caller's client info, read it here and pass a plain value into the command. Do not pass the request type inward.
Returning a nil result with a typed out is fine. In the SDK source, a nil result is replaced with an empty one, the output is marshaled into StructuredContent, and a JSON text copy goes into Content, which matches the spec's advice that structured results also ship as text for older clients.
Domain errors become tool errors, not protocol errors
The spec defines two kinds of failure. Protocol errors are JSON-RPC errors for unknown tools, malformed requests and server faults. Tool execution errors come back as a normal result with isError: true, and they cover business logic errors like "already refunded". The spec says clients SHOULD pass tool execution errors to the model so it can self-correct.
That maps neatly onto the HTTP split between a 409 and a 500. "Order was already refunded" is information the model can act on: stop, or tell the user. A database timeout is not, and its text may contain a host name or a query you do not want in a model's context.
The SDK does the plumbing. When a ToolHandlerFor returns an ordinary Go error, the CallToolResult docs say IsError is set and the error string goes into Content as text. Only a *jsonrpc.Error is sent as a protocol error, per the server code. So the adapter's job is to decide which error text the model is allowed to read:
// internal/adapter/mcpserver/errors.go
package mcpserver
import (
"errors"
"log"
"example.com/shop/internal/domain"
)
func toolError(err error) error {
switch {
case errors.Is(err, domain.ErrOrderNotFound),
errors.Is(err, domain.ErrAlreadyRefunded),
errors.Is(err, domain.ErrInvalidAmount):
return err
}
log.Printf("refund_order: %v", err)
return errors.New("internal error")
}
The log.Printf is deliberate. Over stdio, the spec says the server MUST NOT write anything to stdout that is not a valid MCP message, and MAY log to stderr. Go's default logger writes to standard error, so it is safe. A stray fmt.Println in an adapter is not: it corrupts the message stream.
The binary is a few lines of wiring. Here the in-memory store stands in for your real Postgres adapter:
// cmd/shop-mcp/main.go
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
"example.com/shop/internal/adapter/mcpserver"
"example.com/shop/internal/adapter/memstore"
"example.com/shop/internal/app"
"example.com/shop/internal/domain"
)
func main() {
orders := memstore.New(domain.Order{ID: "A-100", PaidCents: 4999})
svc := app.RefundService{Orders: orders}
srv := mcpserver.New(svc)
ctx := context.Background()
if err := srv.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
To try it from Claude Code, build it and register it as a local stdio server with claude mcp add:
go get github.com/modelcontextprotocol/go-sdk@v1.8.0
go build -o bin/shop-mcp ./cmd/shop-mcp
claude mcp add --transport stdio shop -- "$PWD/bin/shop-mcp"
Prove the domain stayed clean
"We didn't touch the domain" is a claim. Make it a check. go list -deps walks a package and everything it imports, as the go command docs describe. Run it on the inner packages and grep for anything that should not be there:
# from the module root; fails if go list fails or anything matches
deps=$(go list -deps ./internal/domain ./internal/app) &&
! grep -E 'modelcontextprotocol|/adapter/' <<<"$deps"
go.mod did change, because the module now requires the SDK. The domain's dependency list did not. That is the distinction this check enforces.
A stricter version lives in the domain package as a test. go list -json reports a Standard field on every package, true for the standard library, so the test fails if the domain depends on anything outside the standard library except itself:
// internal/domain/imports_test.go
package domain_test
import (
"bytes"
"encoding/json"
"os/exec"
"testing"
)
const self = "example.com/shop/internal/domain"
func TestDomainImportsOnlyStdlib(t *testing.T) {
cmd := exec.Command("go", "list", "-deps", "-json", ".")
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(bytes.NewReader(out))
for dec.More() {
var p struct {
ImportPath string
Standard bool
}
if err := dec.Decode(&p); err != nil {
t.Fatal(err)
}
if !p.Standard && p.ImportPath != self {
t.Errorf("domain depends on %s", p.ImportPath)
}
}
}
The . works because go test runs each test binary from inside the package's source directory. And because the command has no -test flag, go list reports the package itself rather than its test binary, so the test's own imports (os/exec, encoding/json) are not part of what it checks.
Test the use case through the fake, then the adapter in memory
The fake is a driven adapter that keeps orders in a map. It implements app.Orders and returns the domain's not-found error:
// internal/adapter/memstore/orders.go
package memstore
import (
"context"
"sync"
"example.com/shop/internal/domain"
)
type Orders struct {
mu sync.Mutex
m map[string]domain.Order
}
func New(seed ...domain.Order) *Orders {
s := &Orders{m: make(map[string]domain.Order)}
for _, o := range seed {
s.m[o.ID] = o
}
return s
}
func (s *Orders) Get(
_ context.Context, id string,
) (domain.Order, error) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.m[id]
if !ok {
return domain.Order{}, domain.ErrOrderNotFound
}
return o, nil
}
func (s *Orders) Save(_ context.Context, o domain.Order) error {
s.mu.Lock()
defer s.mu.Unlock()
s.m[o.ID] = o
return nil
}
The use case test runs steps in order against one order, because the second refund depends on the first:
// internal/app/refund_test.go
package app_test
import (
"context"
"errors"
"testing"
"example.com/shop/internal/adapter/memstore"
"example.com/shop/internal/app"
"example.com/shop/internal/domain"
)
func TestRefund(t *testing.T) {
ctx := context.Background()
orders := memstore.New(domain.Order{ID: "A-1", PaidCents: 500})
svc := app.RefundService{Orders: orders}
steps := []struct {
id string
cents int64
want error
}{
{"missing", 100, domain.ErrOrderNotFound},
{"A-1", 501, domain.ErrInvalidAmount},
{"A-1", 0, domain.ErrInvalidAmount},
{"A-1", 200, nil},
{"A-1", 100, domain.ErrAlreadyRefunded},
}
for _, s := range steps {
_, err := svc.Refund(ctx, app.RefundOrder{
OrderID: s.id, Cents: s.cents,
})
if !errors.Is(err, s.want) {
t.Errorf("Refund(%s, %d) = %v, want %v",
s.id, s.cents, err, s.want)
}
}
}
No MCP anywhere in it. That test was true before the third door existed and it stays true after.
The adapter gets its own test, over the SDK's in-memory transports so no process is spawned. The package examples connect the server to one end of NewInMemoryTransports and a client to the other. The assertion is the one that matters for agents: a missing order comes back as a tool result with IsError set, not as a protocol failure.
// internal/adapter/mcpserver/server_test.go
package mcpserver_test
import (
"context"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"example.com/shop/internal/adapter/mcpserver"
"example.com/shop/internal/adapter/memstore"
"example.com/shop/internal/app"
"example.com/shop/internal/domain"
)
func TestUnknownOrderIsToolError(t *testing.T) {
ctx := context.Background()
svc := app.RefundService{Orders: memstore.New()}
st, ct := mcp.NewInMemoryTransports()
_, err := mcpserver.New(svc).Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
c := mcp.NewClient(&mcp.Implementation{
Name: "test", Version: "v0",
}, nil)
cs, err := c.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{
Name: "refund_order",
Arguments: map[string]any{
"order_id": "nope", "cents": 100,
},
})
if err != nil {
t.Fatalf("got a protocol error: %v", err)
}
if !res.IsError {
t.Fatal("want IsError on the tool result")
}
text := res.Content[0].(*mcp.TextContent).Text
if text != domain.ErrOrderNotFound.Error() {
t.Fatalf("model would read %q", text)
}
}
What the diff looks like
Count the files. New: internal/adapter/mcpserver/server.go, errors.go, server_test.go, cmd/shop-mcp/main.go, and one test-only guard, internal/domain/imports_test.go. Changed: go.mod and go.sum. Untouched: every production file under internal/domain and internal/app.
That is what ports and adapters buy you. A new protocol showed up and it cost one adapter, because the use case was already reachable through an interface that knew nothing about HTTP. If adding MCP means editing the refund rules, the rules lived in the HTTP handler, and MCP is only the first new client to make that visible.
One more habit worth keeping: write domain error messages as if a model will read them, because now one might. "Refund must be between 1 cent and the amount paid" gives an agent something to fix. "invalid input" does not.
If this was useful
This is the kind of change Hexagonal Architecture in Go is built around: where ports live and which package owns which types, so a new transport is a small diff instead of a rewrite. If your Go service is about to grow an MCP door, a queue consumer or a gRPC endpoint, the book covers the layout and the persistence side that this post only sketched with an in-memory store.

Top comments (0)