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.

  • Preview
  • MIT
  • image // helm chart
  • MCP 2026-07-28

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.kdl and 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.

What absence means, per serve mode
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.#

  1. umbra v0.170.0 Writes the policy.
  2. mcp-beaver Preview Renders that same guardfile into a guarded MCP server. You are here.
  3. agent-compose v2.61.0 Composes the context around it, carrying no authority.

Repository and docs

Reference#