·

[dedo] 3/5: Adding dedo to an Existing Service

Raveesh Agarwal

·

·

6–10 minutes
Five stacked layers: annotations, lint, generators, runtime and agent guidance.

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

LayerWhat you addWhat you get
1. AnnotationsThe dedo.v1 options moduleA place in the proto for scopes, errors, cache, deprecation, tool text
2. Lintbuf-plugin-dedo-lintFindings in the editor and in CI
3. Generatorsprotoc-gen-dedo-mcp, protoc-gen-dedo-openapiMCP tools and REST docs from the proto
4. Runtimepolicy, interceptor, flowid, Vanguard, mcpserverScopes enforced, headers set, every transport on one server
5. Agent guidanceA section in AGENTS.md or CLAUDE.mdCoding agents that follow the same rules

Before you start:

  • You need Go 1.25 or later and the Buf CLI.
  • buf.yaml must be version v2. If yours is v1, run buf config migrate first.

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)
Connect and gRPC, REST and MCP all mount on one connect.Server whose interceptor chain runs flowid, auth, the dedo guard and validate before your handler.

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:

  1. Verify the token.
  2. Put the caller’s scopes in the context.
  3. 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:

  1. A scope you named wrong in the proto.
  2. A token issuer that grants the wrong scopes.
  3. 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 repositoryWhat dedo init does
Go module with protos and buf.gen.yamlKeeps 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.yamlWrites one with managed mode and four generators through go tool.
Protos and buf.yaml, no GoAdds the options module and the BSR lint plugin. Nothing that needs Go.
Go module, no protosWrites a starter service that passes lint.

A few more details:

  • It writes main.go and server.go only when you have none. When you have a main.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 -offline edits the files and prints the go, buf and make commands, 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:

  1. Week 1. Annotations and lint. Make the findings visible. Fix nothing yet.
  2. Weeks 2–3. Declare one service end to end. Clear its findings.
  3. Week 4. Runtime in audit mode. Read the warnings.
  4. One release later. Enforce scopes.
  5. 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

Raveesh Agarwal is a full stack builder writing about Kubernetes, agentic AI and distributed systems. He runs a four-node Kubernetes cluster on his desk. More about me · Follow via RSS

Comments

One response to “[dedo] 3/5: Adding dedo to an Existing Service”

  1. […] Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce. […]

Leave a Reply

Your email address will not be published. Required fields are marked *