dedo
Define once, deploy everywhere. Author one .proto, ship it everywhere. The same contract serves your API as RPC (Connect and gRPC), REST and MCP tools for AI agents, with the same auth, documentation and behaviour on every transport:
From one .proto you get |
How |
| RPC over Connect and gRPC |
connect-go |
| REST, with resource-style URLs |
google.api.http annotations, served by Vanguard |
| REST reference docs (OpenAPI 3.1) |
protoc-gen-dedo-openapi |
| MCP tools for AI agents |
protoc-gen-dedo-mcp generates each service's tools (schemas, declared text, hints and error reasons, typed calls); mcpserver.AddTools serves them |
| Authorization, cache and deprecation headers, flow IDs, error audit |
The policy runtime, read from options in the .proto |
One command to set up. dedo init adds all of it to a Go repository and writes a main.go that serves your service on every transport.
First-class lint guidance. A Buf check plugin turns the standard into findings in your editor as you type, each linked to an explanation in RULES.md. The same buf lint gates CI. Handlers carry business logic only.
New here? Everything is in guidance/: install.md adds dedo to a Go service, one part at a time or all at once with dedo init, tutorial.md builds a new product from scratch, guideline.md explains the reasoning, RULES.md documents every lint rule, and claude.md is guidance for coding agents to append to your AGENTS.md or CLAUDE.md.
Latest release: v0.11.1. The options module is buf.build/r-a/dedo. The lint plugin comes as native binaries on the GitHub release (make tools) and as a Wasm plugin on the BSR. The Go modules are github.com/raveesh-me/dedo/...@v0.11.1.
Contract rules come from Google's AIPs. Operational rules (audience, ownership, scopes, caching, deprecation, error reasons) come from Zalando's guidelines. Both live in the schema.
What is in this repository
| Component |
Path |
Job |
| Options module |
proto/dedo/v1/options.proto |
Declares audience, owner, scopes, cache policy, deprecation, error reasons, sensitive fields, message descriptions, and how each RPC appears as an MCP tool (title, description and the MCP specification's ToolAnnotations). Published as buf.build/r-a/dedo. |
| Lint plugin |
lint/ |
buf-plugin-dedo-lint: Google's AIP rules (AIP_*, through api-linter), five Zalando rules (ZALANDO_*) and nine dedo rules (DEDO_*), each in the category of its prefix. RULES.md documents every rule, the tiebreakers between sources and what dedo skips. Released as native binaries per platform (fast in editors) and as the Wasm check plugin buf.build/r-a/dedo-lint. |
dedo command |
cmd/dedo |
dedo init sets a Go repository up: the options module, the lint plugin and its categories, the generators, the server libraries, the REST docs page, a policy-table test and the agent guidance, editing existing buf.yaml and buf.gen.yaml in place. It writes a main.go and a server.go stub for the repository's service, and a starter service when there is none. The templates are bundled into the binary; dedo templates prints them. |
| Policy table |
policy/ |
Reads the options from descriptors at startup. Fails on a missing declaration. |
| Flow ID |
flowid/ |
Reads or creates X-Flow-ID and propagates it on outbound calls. |
| Policy interceptor |
interceptor/ |
Checks scopes, sets Cache-Control, Deprecation and Sunset, and logs undeclared error reasons. |
| REST doc generator |
openapi/ |
protoc-gen-dedo-openapi: an OpenAPI 3.1 document from the google.api.http bindings, with scopes, error reasons, deprecation and the declared descriptions. Connect routes are left out. |
| MCP tools |
mcp/ |
protoc-gen-dedo-mcp generates <Service>MCPTools() for every service: each unary, non-deprecated RPC as a tool under a snake_case name (get_todo), with its declared title, description, ToolAnnotations and error reasons, input and output schemas (field descriptions from comments, constraints from field_behavior, resource patterns and buf.validate, partial-update and create semantics from AIP), and a typed call. mcpserver.AddTools serves them on the official MCP Go SDK, with no reflection while serving. Each call goes over Connect through the service's own HTTP stack, so tool calls run through the same auth, validation and policy as REST. |
| Templates |
templates/ |
buf.yaml, buf.gen.yaml, Makefile.api, VS Code settings, a Jenkins stage and a policy-table test for service repositories. The dedo command (cmd/dedo) bundles them, with guidance/claude.md. |
| Examples |
examples/ |
library: a contract-only module. todo and bad-todo: two runnable services with the same interface, one compliant and one breaking every rule. house-rules: an organisation's shared HOUSE_* rules, which both import. |
The root Go module (github.com/raveesh-me/dedo) holds the runtime and the generated options code. lint/, openapi/, mcp/ and cmd/dedo/ are separate modules, so services pull in only what they use. A go.work workspace builds them against each other locally, so no go.mod carries a replace and go install <module>@<version> works.
Declaring a contract
One RPC with every declaration the standard asks for, from examples/library (which CI lints):
service LibraryService {
option (dedo.v1.service) = {
audience: AUDIENCE_EXTERNAL_PUBLIC
owner_team: "library-team"
};
// GetBook returns one book.
//
// Errors: BOOK_NOT_FOUND.
rpc GetBook(GetBookRequest) returns (GetBookResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
option (google.api.http) = {
get: "/v1/{name=shelves/*/books/*}"
response_body: "book"
};
option (dedo.v1.method) = {
tool: {
title: "Get a book"
description: "Returns one book by its resource name, such as shelves/1/books/2."
annotations: {
read_only_hint: true
open_world_hint: false
}
}
scopes: "library.book.read"
cache: {
visibility: VISIBILITY_PRIVATE
max_age: {seconds: 30}
}
errors: {
reason: "BOOK_NOT_FOUND"
code: NOT_FOUND
}
};
}
}
// GetBookRequest is the request of GetBook.
message GetBookRequest {
option (dedo.v1.message) = {description: "Which book to get."};
// The resource name of the book.
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "library.raveesh.me/Book"}
];
}
See examples/library/proto for a complete module.
Lint rules
buf-plugin-dedo-lint runs three classes of rules: AIP_* (Google's AIPs, through api-linter), ZALANDO_* (the Zalando guidelines dedo adopts) and DEDO_* (dedo's own). When they disagree, DEDO wins over AIP, and AIP over ZALANDO. HOUSE_* rules are your organisation's own, written as CEL checks in rules.yml and importable from a shared git repository; examples/house-rules shows them. RULES.md has the tiebreakers and every rule dedo skips, by source.
| Rule ID |
Checks |
ZALANDO_SERVICE_RULES_REQUIRED |
Every service declares the service option with an audience and an owner team |
DEDO_METHOD_RULES_REQUIRED |
Every RPC declares the method option with at least one scope and one error reason |
ZALANDO_SCOPE_NAME_FORMAT |
Scopes match <app>.<resource>.<read|write> |
DEDO_ERROR_REASON_FORMAT |
Reasons are UPPER_SNAKE_CASE. Codes are neither OK nor UNKNOWN. |
ZALANDO_CACHE_REQUIRES_NO_SIDE_EFFECTS |
A cache policy appears only on NO_SIDE_EFFECTS methods, with a visibility and a positive max age |
ZALANDO_DEPRECATION_CONSISTENT |
deprecated = true and a deprecation schedule appear together, sunset follows deprecation, and the successor exists |
DEDO_READ_METHOD_NO_SIDE_EFFECTS |
Unary Get* and List* RPCs set idempotency_level = NO_SIDE_EFFECTS |
DEDO_HTTP_RESPONSE_BODY |
HTTP rules of Get*, Create* and Update* RPCs set response_body |
DEDO_HTTP_PATH_VERSION |
Every HTTP path, additional bindings included, contains the package version, such as /v1/, after a root that is the same for the whole service. The root itself, such as /commerce/pim, is the owner's choice. |
DEDO_NO_JSON_NAME |
No field overrides the protojson default name |
ZALANDO_MONEY_FIELD_TYPE |
Fields named price, amount, *_price or *_amount use google.type.Money |
DEDO_TOOL_REQUIRED |
Every unary RPC declares a tool title and description for MCP and the REST docs |
DEDO_TOOL_ANNOTATIONS |
Every unary RPC declares the MCP ToolAnnotations hints (spec 2026-07-28), consistent with its idempotency_level |
DEDO_MESSAGE_DESCRIPTION_REQUIRED |
Every message declares a consumer-facing description |
RULES.md explains each rule with examples. Every finding ends with a link to its section, pinned to the plugin's release tag.
Suppress one finding with // buf:lint:ignore ZALANDO_MONEY_FIELD_TYPE above the element, followed by the reason.
Using it in a service repository
go install github.com/raveesh-me/dedo/cmd/dedo@v0.11.1
dedo init
dedo init adds the options module, the lint plugin, the code generators, the server libraries and the agent guidance where they are missing, then writes a main.go for your service (or, when you have one, prints dedo's to compare). In an empty module it writes a starter service that passes lint, so make api-generate and go run . work straight away. The templates are bundled into the binary (dedo templates lists them), so nothing is cloned. install.md shows each step by hand, and tutorial.md builds a service from scratch. In short:
dedo init (or by hand: copy buf.yaml, buf.gen.yaml, Makefile.api from templates/, pin the generators and the lint plugin with go get -tool, run dedo tools and buf dep update).
- Fill in
CODEOWNERS and the VS Code settings from templates/ if you use them.
- Write the contract, then
make api-check (lint, format, breaking) and buf generate (Go, Connect, MCP tools, REST docs).
- Wire the runtime on one connect-go v2
connect.Server: policy.Build for the policy table, interceptor.New for scopes and headers, connecthttp.Mount for Connect and gRPC, vanguard.Mount for REST, mcpserver.AddTools with the generated <Service>MCPTools() for MCP, and the docs handler. Start with EnforceScopes: false (audit mode), then enforce.
- Keep the policy-table test
dedo init writes, so a missing declaration fails go test before deploy.
The options module sets its own go_package, so generated code links against the runtime's types with or without Buf's managed mode. With managed mode, buf.gen.yaml keeps a module-scoped override for it (dedo init adds it).
Clients need nothing from dedo: a dedo API is a Buf module, so connect-es, connect-query, Buf's generated SDKs or any OpenAPI client generator (from docs/openapi.json) work as with any other.
Upgrading: dedo update installs the latest dedo, and dedo upgrade moves a repository to it: the options module, the lint plugin, the generators and the runtime as one release, then buf.lock and the generated code. In an existing Buf repository, dedo init keeps your generators (remote plugins included) and adds only dedo's; a repository of protos without Go gets the options and the BSR lint plugin. install.md has the details.
Every transport reaches one connect-go v2 connect.Server, so its interceptors (flow IDs, auth, dedo's policy, validation) run for Connect, gRPC, REST and MCP alike, streams included, and error responses carry the deprecation headers too. Auth belongs in a server interceptor, not HTTP middleware: MCP calls reach the server in-process.
Examples: todo and bad-todo
Two Go services, each laid out like a service repository built from templates/, with its own buf.yaml, buf.gen.yaml, go.mod and proto files. Both serve the same TodoService over Connect, gRPC and REST (through Vanguard), with the same server code and the full runtime: policy table, scope interceptor, flow IDs and error audit.
examples/todo follows the standard. buf lint is clean. Its tests check scopes, CRUD, pagination, cache and deprecation headers, REST unwrapping and the error audit end to end. It also serves the REST reference at /docs (Stoplight Elements, rendering the generated /openapi.json) and an MCP server at /mcp, from the generated TodoServiceMCPTools(). Its tests check that MCP tools carry the declared text and ToolAnnotations and enforce the same scopes, and that a change made through MCP reaches an open WatchTodos stream at once.
examples/bad-todo breaks all fourteen ZALANDO and DEDO rules, plus one Buf rule, one AIP rule and both example HOUSE rules. Open proto/raveesh_me/todo/v1/todo.proto in VS Code to see the findings. expected-lint.txt lists them, and app/app_test.go shows what each mistake does at runtime: a misnamed scope locks out writers, an unversioned path moves the endpoint, undeclared reasons show up in logs, and a custom json_name renames the field for every JSON client.
examples/todo also runs as a deployment, with everything built in containers (podman, kind and kubectl are the only host tools):
cd examples/todo
make up # kind cluster "todo-example": the service and a live Vue UI at http://localhost:8090
make e2e # Playwright: an agent adds a to-do over MCP and the open UI shows it without a reload
make down
Every generator runs locally, nothing on Buf's servers: the Go generators through go tool, and the UI's protoc-gen-es in a Node container (make web-gen), pinned with the runtime it must match. The UI (web/) uses TanStack Query through connect-tan-vue's createClientWithQuery, and stays live through the WatchTodos server stream. examples/todo/MCP.md shows how to connect Claude Code or Claude Desktop to the service's MCP endpoint.
The examples test this repository's working tree, not a release: each vendors options.proto under third_party/ and loads the lint plugins from dist/. Run make plugins once so the editor finds them, then make examples. Rerun make plugins after changing a rule. Service repositories use the published module and plugins instead.
cd examples/todo && go run . &
curl -H 'Authorization: Bearer writer' -H 'Content-Type: application/json' -d '{"title":"try it"}' localhost:8080/commerce/todo/v1/todos
curl -i -H 'Authorization: Bearer reader' localhost:8080/commerce/todo/v1/todos/1
open http://localhost:8080/docs
The plugin runs natively wherever it can. On examples/todo, one buf lint with buf-plugin-dedo-lint takes:
| Plugin form |
Time |
| Native binary (default) |
0.30 s |
| WebAssembly |
5.0 s |
The editor reruns the lint as you type, so this is the difference between findings that keep up and findings that lag. The WebAssembly module is 40 MB, most of it Google's api-linter. Buf caches the compiled module, so the cost is in running it, and a smaller build does not help. buf.yaml refers to the native binary by path, so an editor started outside a shell finds it too. The WebAssembly plugin on the BSR stays available for setups that cannot download binaries, such as Windows.
Developing
make check # format, generate, Go tests, plugin builds, buf lint, examples
make plugins # buf-plugin-dedo-lint and the example house plugin, natively, into dist/
make wasm-plugins # buf-plugin-dedo-lint as WebAssembly, for the BSR
make breaking # options module against main
make check needs buf. Code generators are pinned in each go.mod as Go tools and run through go tool. The root buf.yaml lints the options module (Buf rules only, since it defines the extensions) and examples/library (all rules, with the locally built plugin from dist/).
Lint rule tests live in lint/rules/testdata/<rule>/{pass,fail}. A pass fixture must produce no finding. A fail fixture marks each line that must produce one with // want.
Releasing
A pull request that breaks the options module fails CI's buf breaking check. When the break is deliberate, label the PR allow-breaking, re-run the check, and release a new minor version.
Before tagging, set the new version in the @vX.Y.Z pins in guidance/install.md, guidance/tutorial.md and guidance/guideline.md, the version constant in cmd/dedo/main.go, and the readme's "Latest release" line. The release job runs scripts/check-release-versions.sh first and stops if any of them names another version.
To release, tag the merge commit on main with lint/vX.Y.Z, openapi/vX.Y.Z, mcp/vX.Y.Z and cmd/dedo/vX.Y.Z (so go get and go install work for the nested modules) and push those first, at most three per push. Then push vX.Y.Z on its own: GitHub starts no workflow when more than three tags arrive in one push.
The v* tag runs .github/workflows/ci.yaml, which publishes one version everywhere:
buf push of the options module with the tag and main as labels; main is the default label that unpinned dependencies resolve to
buf plugin push of buf.build/r-a/dedo-lint
- a GitHub release carrying the
dedo command, the lint plugin and both generators (protoc-gen-dedo-mcp, protoc-gen-dedo-openapi) as native binaries for macOS and Linux (arm64 and amd64), and the lint plugin as a .wasm file.
The Go module version is the same tag. The job reads a BUF_KEY repository secret: a Buf token for the r-a organization with module and plugin create and push scopes.
The AIP_* rules run Google's api-linter inside buf-plugin-dedo-lint, an approach adapted from hestiatechnology/buf-plugin-aip (both Apache-2.0; see NOTICE). Dependabot keeps api-linter current.
Keeping up with the standards
Two weekly jobs keep dedo current with the sources it follows:
- AIPs. Dependabot (
.github/dependabot.yml) opens a pull request for each new release of api-linter, which carries the AIP_* rules. If a new or changed rule fires on examples/bad-todo, CI fails on expected-lint.txt; decide whether to follow the rule or skip it, and record the decision in RULES.md. The MCP go-sdk gets its own pull request the same way.
- MCP specification.
.github/workflows/mcp-spec.yaml runs scripts/check-mcp-spec.sh every Monday. It compares the newest dated specification and the draft with the Tool definition vendored in mcp/mcpserver/testdata, and checks the tools mcpserver serves against each. When a newer version is out, the definition changed, or the tools no longer match, it opens (or updates) one issue labelled mcp-spec with the report. Run it locally with scripts/check-mcp-spec.sh report.md.
Why "dedo"?
dedo stands for define once, deploy everywhere.
It is also a word heard a lot in the author's home. The author's daughter is one, and whenever she wants something she says "de do", Hindi for "give it to me". She says it so often that the family has started calling her Dedo.
The project works the same way: you hand it one .proto, and it gives back RPC, REST, docs and MCP tools.
License
Apache License 2.0.