Most of us work on services that already exist.
- You have a Go service with protos.
- It may already use Buf, with remote plugins and managed mode.
- It has callers who depend on it today.
dedo is built for that service. It installs in layers. Each layer works on its own.
You can stop after any layer and still be better off than before.
This post walks through the layers in order. Then it shows the one command that does them all.
The layers
| Layer | What you add | What you get |
|---|---|---|
| 1. Annotations | The dedo.v1 options module | A place in the proto for scopes, errors, cache, deprecation, tool text |
| 2. Lint | buf-plugin-dedo-lint | Findings in the editor and in CI |
| 3. Generators | protoc-gen-dedo-mcp, protoc-gen-dedo-openapi | MCP tools and REST docs from the proto |
| 4. Runtime | policy, interceptor, flowid, Vanguard, mcpserver | Scopes enforced, headers set, every transport on one server |
| 5. Agent guidance | A section in AGENTS.md or CLAUDE.md | Coding agents that follow the same rules |
Before you start:
- You need Go 1.25 or later and the Buf CLI.
buf.yamlmust be version v2. If yours is v1, runbuf config migratefirst.
Layer 1: Annotations
Add the options module and the two modules it builds on:
# buf.yaml
version: v2
deps:
- buf.build/googleapis/googleapis
- buf.build/bufbuild/protovalidate
- buf.build/r-a/dedo:v0.11.1Code language: YAML (yaml)
buf dep updateCode language: Bash (bash)
Pin the label to the dedo release you use. The options, the lint rules that read them and the runtime that enforces them then agree.
Then import the options where you need them:
import "dedo/v1/options.proto";Code language: Protocol Buffers (protobuf)
Nothing changes at runtime yet. Your protos still compile.
You have a vocabulary.
Layer 2: Lint
Pin the lint plugin in go.mod, then build it:
go get -tool github.com/raveesh-me/dedo/lint/cmd/[email protected]
dedo tools # builds .tools/buf-plugin-dedo-lint from the pin
printf '.tools/\n' >> .gitignoreCode language: Bash (bash)
The plugin runs as a native binary. Buf starts it about a dozen times per lint, and the editor needs it fast.
Pinning it in go.mod means every laptop and CI build the same version.
Turn the categories on:
# buf.yaml
lint:
use:
- STANDARD
- COMMENTS
- AIP_NOLANG
- DEDO
- ZALANDO
except:
- AIP_0131_RESPONSE_MESSAGE_NAME
# ...the rest of the list in templates/buf.yaml
plugins:
- plugin: .tools/buf-plugin-dedo-lintCode language: YAML (yaml)
The except list holds the AIP rules dedo skips on purpose. RULES.md gives the reason for each.
Now run it:
make api-check # buf format, buf lint, buf breaking against mainCode language: Bash (bash)
Expect a long list the first time.
That list is the most useful thing a migration produces.
It is the distance between your API today and the standard, one missing declaration per line. You do not have to clear it in one pull request.
Working through the list
Take one service at a time. For each RPC, ask the questions the lint asks:
- Who owns this service, and who may call it?
- Which scope grants access?
- Which errors can it return?
- Is it safe to retry? Is it safe to cache?
- How would an agent describe it?
Some answers will surprise you.
Most teams have never written down which errors an RPC returns. The first time they do, they find two that no client handles.
When a rule does not fit, say so in the file:
// buf:lint:ignore DEDO_HTTP_PATH_VERSION
// Legacy partners call /products directly. Retired with v2 in Q3.Code language: Protocol Buffers (protobuf)
A suppression with a reason is a decision a reviewer can agree with, or question.
Layer 3: Generators
Pin the generators as Go tools:
go get -tool google.golang.org/protobuf/cmd/protoc-gen-go
go get -tool connectrpc.com/connect/v2/cmd/[email protected]
go get -tool github.com/raveesh-me/dedo/mcp/cmd/[email protected]
go get -tool github.com/raveesh-me/dedo/openapi/cmd/[email protected]Code language: Bash (bash)
Add dedo’s two generators to buf.gen.yaml, next to yours:
plugins:
# ...your existing generators stay as they are
- local: ["go", "tool", "protoc-gen-dedo-mcp"]
out: gen/go
opt: paths=source_relative
- local: ["go", "tool", "protoc-gen-dedo-openapi"]
out: docs
strategy: all
opt:
- title=Your API
- server=https://api.example.comCode language: YAML (yaml)
If you use managed mode, keep dedo’s Go package out of your prefix. The last matching override wins:
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/you/your-service/gen/go
- file_option: go_package_prefix
module: buf.build/r-a/dedo
value: github.com/raveesh-me/dedo/gen/goCode language: YAML (yaml)
Run buf generate. You now have:
<Service>MCPTools()for every service.- A
docs/openapi.json.
Layer 4: Runtime
This is the layer that touches production, so it deserves care.
dedo runs on connect-go v2. One connect.Server holds the interceptor chain. Every transport mounts on it.
table, err := policy.Build(productsv1connect.ProductServiceName)
if err != nil {
return nil, err // a missing declaration stops the service here
}
guard, err := interceptor.New(interceptor.Config{
Table: table,
Scopes: auth.ScopesFromContext,
EnforceScopes: false, // audit mode first
Log: log,
})
if err != nil {
return nil, err
}
server := connect.NewServer(
flowid.Interceptor(),
auth.Interceptor(),
guard,
validate.NewServerInterceptor(),
)
productsv1connect.RegisterProductServiceHandler(server, newServer())
mux := http.NewServeMux()
connecthttp.Mount(mux, server) // Connect, gRPC, gRPC-Web
if err := vanguard.Mount(mux, server); err != nil { // REST
return nil, err
}Code language: Go (go)

Three things matter here.
Auth belongs in an interceptor
Your existing service may verify tokens in HTTP middleware. Move that into a connect.ServerInterceptor.
MCP tool calls reach the server in-process, through connectinprocess. They never pass through HTTP middleware.
An auth check in HTTP middleware would let every agent through.
The interceptor’s job is small:
- Verify the token.
- Put the caller’s scopes in the context.
- Let dedo’s guard read them from there.
Audit before you enforce
EnforceScopes: false is audit mode.
The guard checks every call against the declared scopes. When a caller lacks one, it logs a warning with the procedure and the flow ID. Then it lets the call through.
Ship that, and watch the logs for a release cycle. Every warning is one of three things:
- A scope you named wrong in the proto.
- A token issuer that grants the wrong scopes.
- A caller that should not have access.
Fix the first two and decide about the third. When the logs are quiet, set EnforceScopes: true.
You learn what enforcement would do before it does it.
Fail closed at startup
Keep the policy-table test dedo init writes:
func TestPolicyTable(t *testing.T) {
if _, err := policy.Build(productsv1connect.ProductServiceName); err != nil {
t.Fatal(err)
}
}Code language: Go (go)
Lint already requires scopes. The runtime checks again, because a missing scope at runtime means an open endpoint.
This test moves that failure from deploy to go test.
MCP and docs
Once the runtime is in, the last two doors are a few lines each:
client := connect.NewClient(connectinprocess.New(server))
tools := mcp.NewServer(&mcp.Implementation{Name: "products", Version: "v1"}, nil)
if err := mcpserver.AddTools(tools, client, productsv1.ProductServiceMCPTools()...); err != nil {
return nil, err
}
mux.Handle("GET /openapi.json", docs.Spec())
mux.Handle("GET /docs", docs.Page())
mux.Handle("/mcp", flowid.Middleware(http.NewCrossOriginProtection().Handler(
mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return tools },
&mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}))))Code language: Go (go)
Every tool call goes over Connect through the same server, with the same auth, scopes and validation.
Layer 5: Agent guidance
Coding agents write a lot of protos now. Teach them the rules too:
dedo templates claude.md >> AGENTS.mdCode language: Bash (bash)
Or: one command
Everything above is what dedo init does:
go install github.com/raveesh-me/dedo/cmd/[email protected]
dedo initCode language: Bash (bash)
It adds what is missing and keeps what is there. Your deps, categories, exceptions, plugins and comments stay. Running it twice changes nothing.
It reads your repository first:
| Your repository | What dedo init does |
|---|---|
Go module with protos and buf.gen.yaml | Keeps your generators, remote plugins included. Adds dedo’s two, writing to the same out with the same paths= option. Leaves managed mode as it is. |
Go module with protos, no buf.gen.yaml | Writes one with managed mode and four generators through go tool. |
Protos and buf.yaml, no Go | Adds the options module and the BSR lint plugin. Nothing that needs Go. |
| Go module, no protos | Writes a starter service that passes lint. |
A few more details:
- It writes
main.goandserver.goonly when you have none. When you have amain.go, it prints dedo’s beside yours to compare. - It names the services that declare nothing for dedo yet. Until they do, the lint lists what each RPC needs, and the policy-table test fails on purpose.
dedo init -offlineedits the files and prints thego,bufandmakecommands, so you can see what it will run first.
Staying current
dedo update # installs the latest dedo
dedo upgrade # moves this repository to itCode language: Bash (bash)
dedo upgrade moves four things together, as one release:
- the options module;
- the lint plugin;
- the generators;
- the runtime.
Then it refreshes buf.lock, rebuilds the plugin and regenerates the code.
Coming from connect-go v1? It also runs connect-go’s own connect-go-v2-migrate and prints the new wiring beside yours.
A suggested order
For a team with a live service:
- Week 1. Annotations and lint. Make the findings visible. Fix nothing yet.
- Weeks 2–3. Declare one service end to end. Clear its findings.
- Week 4. Runtime in audit mode. Read the warnings.
- One release later. Enforce scopes.
- Then. Turn on MCP and docs. They come almost for free by now.
Each step ships on its own, and each one is reversible.
Next
dedo’s rules are a baseline. Your organisation has conventions of its own: domain names, REST roots, field naming.
The next post shows how to write those as lint rules, in a few lines of YAML.
The dedo series
- Part 1: Define Once, Deploy Everywhere: why one proto can carry the contract and its rules.
- Part 2: Getting Started with Todo Good and Todo Bad: two services, one interface, and what each lint rule protects you from.
- Part 3: Adding dedo to an Existing Service (this post): one layer at a time, with an audit mode before you enforce.
- Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository.
- Part 5: Multi-Tenant SaaS Auth: the tenant boundary, declared in the proto and enforced on every transport.

Leave a Reply