README
¶
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 |
| Tenant isolation for multi-tenant APIs (optional) | tenancy in the .proto; the runtime checks every request's tenant, and the opt-in DEDO_MULTI_TENANT lint rules check that every resource and request names one |
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.20.0. The options module is buf.build/r-a/dedo. The lint plugin's release is the options module's label in buf.yaml, and dedo tools installs it natively into any repository with protos; it also comes as native binaries on the GitHub release and as a Wasm plugin on the BSR. The Go modules are github.com/raveesh-me/dedo/...@v0.20.0.
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, tenancy, 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_*), all in one Buf category, DEDO, with dedo.yaml choosing which run. Four more, for multi-tenant services, are opt-in (DEDO_MULTI_TENANT_*), and your organisation's own HOUSE_* rules come from CEL checks in dedo.yaml. RULES.md documents every rule, the order in which rules override each other 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 dedo.yaml, which chooses its rules and names the organisation's agent guidance, the generators, the server libraries, the REST docs page, a policy-table test and the agent guidance (dedo's, then the organisation's annexures, which dedo upgrade keeps current), 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. dedo install installs the lint plugin into $GOBIN, dedo tools copies it into .tools/ at buf.yaml's release, fetches shared house rules and lists every rule override, dedo update installs the latest dedo, dedo uninstall removes it and the lint plugin from $GOBIN, and dedo upgrade moves a repository to it (dedo upgrade -lint moves only the lint plugin). 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 and, in multi-tenant services, each request's tenant; 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 in-process to the service's connect.Server (connectinprocess), so tool calls run through the same interceptors (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. contacts: a runnable multi-tenant service, linted with every rule including DEDO_MULTI_TENANT, whose tokens grant locations; its tests check that no caller reaches another location's data, through a nested reference included, on Connect, REST and MCP. 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). A fourth, HOUSE_*, is your organisation's own: CEL checks in dedo.yaml, importable from a shared git repository or from a release published over HTTPS, pinned by its SHA-256; examples/house-rules shows them.
The classes rank in layers: your repository's own dedo.yaml, then the house rules it imports, then DEDO, AIP and ZALANDO. Nothing guesses at conflicts: a rule names the rules it replaces in overrides, the plugin checks that each ranks below it, and the overridden rules report nothing. Final rules cannot be overridden: three of dedo's, which the runtime depends on, so a contract that lints clean also starts, and any house rule marked final: true, which an organisation's shared rules use to lock a convention for every service. dedo tools lists every override in force. RULES.md has the order, dedo's own overrides 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 |
Multi-tenant services opt into four more, the DEDO_MULTI_TENANT set in dedo.yaml's use: the service declares its tenant collection, resource patterns start with the tenant, every request names its tenant in a field its HTTP path binds, and no tenant data is cached publicly. The runtime enforces the same declaration on every request; RULES.md explains the model.
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.20.0
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). It then generates the code and tidies go.mod, so make run serves the service straight away; in an empty module it first writes a starter service that passes lint. Two commands, dedo init and make run, take an empty Go module to a running API with MCP tools. 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: copybuf.yaml,buf.gen.yaml,Makefile.apifromtemplates/, pin the generators and the lint plugin withgo get -tool, rundedo toolsandbuf dep update).- Fill in
CODEOWNERSand the VS Code settings fromtemplates/if you use them. - Write the contract, then
make check(lint, format, breaking) andmake generate(Go, Connect, MCP tools, REST docs, thengo mod tidy);make runserves it andmake testtests it. - Wire the runtime on one connect-go v2
connect.Server:policy.Buildfor the policy table,interceptor.Newfor scopes, tenants and headers,connecthttp.Mountfor Connect and gRPC,vanguard.Mountfor REST,mcpserver.AddToolswith the generated<Service>MCPTools()for MCP, and the docs handler.MCP_ACCESSdecides who may reach/mcp: anyone (public, the default; every call is still scope-checked) or only callers with a token the service accepts (authenticated). Themain.gothatdedo initwrites enforces scopes from the start; an existing service can begin withEnforceScopes: false(audit mode), then enforce. - Keep the policy-table test
dedo initwrites, so a missing declaration failsgo testbefore 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 lint plugin, installed once per machine by dedo install and copied into its .tools/. 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/todofollows the standard.buf lintis 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 generatedTodoServiceMCPTools(). Its tests check that MCP tools carry the declared text andToolAnnotationsand enforce the same scopes, and that a change made through MCP reaches an openWatchTodosstream at once.examples/bad-todobreaks all fourteen ZALANDO and DEDO rules (the opt-in multi-tenant ones areexamples/contacts's concern), plus one Buf rule, one AIP rule and both example HOUSE rules. Openproto/raveesh_me/todo/v1/todo.protoin VS Code to see the findings.expected-lint.txtlists them, andapp/app_test.goshows 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 customjson_namerenames 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 plugin from dist/. Run make plugins once so the editor finds it, then make examples. Rerun make plugins after changing a rule. Service repositories use the published options module and the plugin pinned in their go.mod 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
Lint performance
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.34 s |
| WebAssembly | 5.9 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 49 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. Repositories without go.mod get the same native binary: dedo install puts it in $GOBIN, and dedo tools copies it into the repository's .tools/. The WebAssembly plugin on the BSR cannot read dedo.yaml; dedo tools -wasm builds one with the house rules built in, for an organisation to push to the BSR.
Developing
make check # format, generate, Go tests, plugin build, buf lint, examples, the starter
make plugins # buf-plugin-dedo-lint, 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, and podman for the todo UI's TypeScript generator, which runs in a Node container. 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), examples/library (Buf, DEDO and ZALANDO rules) and examples/contacts (every rule a service gets, with DEDO_MULTI_TENANT), with the locally built plugin from dist/.
Benchmarks
BenchmarkMCPHop in examples/todo/app and examples/contacts/app times one tool call through the real /mcp handler, scopes enforced and a valid token, with the tools reaching the server in-process or over loopback Connect:
cd examples/todo && go test -run='^$' -bench=BenchmarkMCPHop -benchmem -count=10 ./app
bench/mcp/run.sh runs the same calls between containers with equal CPU and memory limits, against three servers: dedo's MCP in-process, dedo's MCP forwarding to the service over Connect, and a NestJS MCP server built the way HighLevel's MCP guidelines describe (bench/mcp/nest-todo), forwarding over REST. Every call passes the same scope and tenant checks, and the load driver stops on any failed call. It needs podman, and writes its results outside the repository.
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
Before every release, bring this readme and everything in guidance/ up to date with what the release changes, and rerun tutorial.md against it, since it promises that every step was run as written.
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 options module's label in templates/buf.yaml and guidance/install.md, the version constant in cmd/dedo/main.go, the readme's "Latest release" line, the replacements in go.work, and each module's requirement on the others (lint, openapi, mcp, cmd/dedo and both examples). The release job runs scripts/check-release-versions.sh first and stops if any of them names another version.
Until an MCP Go SDK release decodes JSON in place (go-sdk#1454), services replace the SDK release dedo is built with by dedo's build of it: that release plus the fix, tagged vX.Y.Z-dedo.N on raveesh-me/go-sdk. When a release moves dedo to another SDK release, tag the fix onto it there first and set sdkRelease and sdkForkRelease in cmd/dedo/sdkfork.go and the replaces in both examples' go.mod; the release check stops if the tag is not published or the examples name another one. Once an SDK release has the fix, set sdkForkRelease to "" and remove the examples' replaces: dedo upgrade then removes the line from services.
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 pushof the options module with the tag andmainas labels;mainis the default label that unpinned dependencies resolve tobuf plugin pushofbuf.build/r-a/dedo-lint- a GitHub release carrying the
dedocommand, 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.wasmfile.
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 theAIP_*rules. If a new or changed rule fires onexamples/bad-todo, CI fails onexpected-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.yamlrunsscripts/check-mcp-spec.shevery Monday. It compares the newest dated specification and the draft with theTooldefinition vendored inmcp/mcpserver/testdata, and checks the toolsmcpserverserves against each. When a newer version is out, the definition changed, or the tools no longer match, it opens (or updates) one issue labelledmcp-specwith the report. Run it locally withscripts/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
Documentation
¶
Overview ¶
Package dedo holds what a service copies from dedo: the templates and the guidance for coding agents. The dedo command (cmd/dedo) writes them into a repository.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var Files embed.FS
Files holds templates/, guidance/claude.md and guidance/generators.md.
Functions ¶
This section is empty.
Types ¶
This section is empty.
Directories
¶
| Path | Synopsis |
|---|---|
|
bench
|
|
|
mcp/load
command
Command load drives one MCP tool call against a server's /mcp endpoint from concurrent clients for a fixed time, and prints the latency distribution and throughput as one JSON line.
|
Command load drives one MCP tool call against a server's /mcp endpoint from concurrent clients for a fixed time, and prints the latency distribution and throughput as one JSON line. |
|
cmd
|
|
|
dedo
module
|
|
|
Package flowid reads or creates the X-Flow-ID header and propagates it on outbound calls.
|
Package flowid reads or creates the X-Flow-ID header and propagates it on outbound calls. |
|
gen
|
|
|
Package interceptor applies the declared operational contract of each RPC: it checks scopes, sets cache and deprecation headers, and logs undeclared error reasons.
|
Package interceptor applies the declared operational contract of each RPC: it checks scopes, sets cache and deprecation headers, and logs undeclared error reasons. |
|
internal
|
|
|
testsvc
Package testsvc builds an in-memory service descriptor with declared options, for tests of the runtime packages.
|
Package testsvc builds an in-memory service descriptor with declared options, for tests of the runtime packages. |
|
lint
module
|
|
|
mcp
module
|
|
|
openapi
module
|
|
|
Package policy reads the declared operational contract of each RPC from its descriptors once, at startup.
|
Package policy reads the declared operational contract of each RPC from its descriptors once, at startup. |