One file // the whole server
mcp-beaver
a MCP server generator with a natural flow
You need an MCP server for one API. Now write a Go package, a handler and a schema per tool, and a Dockerfile.
wrap ward mcp forgejo {
restrict owner matches "coilyco-*"
can get issue {
path "/repos/{owner}/{repo}/issues/{index}"
}
}
withhold "delete_issue" { reason "No undo." }
One grant becomes one MCP tool and one HTTP endpoint, and umbra derives the input schema from the path, query, and body written above.
Deny by absence
5 grants mint 5 tools and 5 endpoints delete_issue withheld, and says so 30 others never named, so never served
The problem, and what it costs to leave alone
By the fourth service it is four codebases.#
Exposing one service to an agent means writing a Go package, a handler per tool, an input schema per tool, and a Dockerfile. Then a reviewer reads all four and decides whether they believe the combination. Do it again for the next service, and the honest answer to what an agent can reach through them is that nobody has held all of it in their head at once.
Each of those servers holds a credential and forwards calls, so its blast radius is the whole upstream, and the thing standing between the agent and a destructive verb is that nobody wrote a tool for it yet. Somebody will, because adding one is a small pull request.
What it does about it
One runtime, many guardfiles.#
mcp-beaver renders an umbra guardfile into a guarded
MCP server and a matching HTTP tool API, baked into one generic image. Each
can grant is one MCP tool named verb_resource, and its
input schema is derived from the grant's own path, query, and body. There is no
per-server Go, no per-server Dockerfile, no per-server handler, and no per-tool
schema to keep in sync with anything.
An unwritten delete issue grant means no delete_issue
tool and no /api/delete_issue endpoint is ever served. Deployment is a
values file and a helm upgrade, and the chart stays spec-opaque: in
spec mode it never parses the guardfile at all.
Four ways to serve
- serve
- Reads a
.mcp.kdland guards an HTTP upstream. The general case. - serve-upstream
- Wraps a private MCP behind an exact tool allowlist.
- serve-ssm
- An exact-parameter AWS reader. The policy names one parameter, and the general getter rejects every other name before AWS sees a request. IAM bounds the principal independently, so there are two bounds rather than one.
- serve-s3
- The asset publisher, and the first write-capable mode. Its policy fixes one bucket, the media types it will serve, the public base URL, and an optional key prefix.
lint and lint-upstream are the same paths minus the
listener, so a guardfile is validated in CI before it is ever mounted.
What it looks like in use
The reviewable surface is one small file, read end to end.#
This is the whole contract for a server that can read, file, comment on, and close issues, and can do nothing else to the host it points at.
wrap ward mcp forgejo {
base-url "forgejo.coilysiren.me/api/v1" 1
auth header-token { value env "FORGEJO_TOKEN" } 2
restrict owner matches "coilyco-*" 3
can get issue { path "/repos/{owner}/{repo}/issues/{index}" } 4
}
withhold "delete_issue" { reason "The upstream has no undo." } 5
- 1 base-url
- The one host this server may reach.
- 2 auth
- Resolved from the environment at run time, never baked into the image. This is the credential mcp-beaver presents upstream, not a caller's credential to mcp-beaver.
- 3 restrict
- Every
{owner}path leaf must match, so a granted verb still can't travel outside the accounts you named. - 4 can get
- One grant, one tool, one endpoint, and an input schema derived from the path.
- 5 withhold
- A verb left out on purpose, said out loud. The stub appears in discovery, states why, refuses every call, and reaches no upstream.
That last node exists because silence means four things at once: withheld by policy, unimplemented, not offered upstream, or simply not found by the agent's search. An agent guesses between them, and it guesses wrong in both directions.
A design call worth stating
Every result opens with what it counted.#
Grant-backed results are {"coverage": {...}, "result": ...}, in that
order, in both the text and the structured content. Coverage names every array in
the payload and its length, because a count in meaning is what changes an answer.
Note
Coverage leads because a consuming harness bounds a tool result by keeping the front and discarding the tail. A caveat serialized last is the first thing destroyed, and the model then reads rows carrying no caveat and answers as though the view were complete.
What the deployment owns
Caller identity belongs to the deployment.#
Caller identity, TLS, ingress, and network reachability belong to whatever runs
mcp-beaver, and it expects to sit behind them rather than to be an API gateway or
an identity layer. Guardfile auth configures mcp-beaver's own credential to the
upstream service, which is the opposite direction from a caller's credential to
mcp-beaver.
Note
The deny-by-absence claim is about the running server, not the image. The image is deliberately generic and carries no guardfile, so a consumer mounts the spec at deploy time.
| Mode | An undeclared operation |
|---|---|
| Spec mode | Has no handler at all. Absent, in the strongest sense the word has. |
| Upstream-proxy mode | Still exists behind an endpoint the container holds credentials for. The runtime re-checks allowlist membership on every call, so the bound there is enforcement rather than absence. |
The distinction is worth carrying, because the two modes earn different sentences and only one of them earns the stronger one.
One boundary, three proofs
The whole reviewable surface is one file a person can read.#
- umbra v0.170.0 Writes the policy.
- mcp-beaver Preview Renders that same guardfile into a guarded MCP server. You are here.
- agent-compose v2.61.0 Composes the context around it, carrying no authority.
Repository and docs