mcp server
evenkeel
A FastAPI backend template that keeps its claims checkable — layer contracts, adapter conformance and the OpenAPI document are CI jobs, not README promises. The example is a wallet ledger: money, conc
Set up
Runs on your computer. Pick your agent and operating system.
Before you start
- uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
{
"mcpServers": {
"evenkeel": {
"command": "uvx",
"args": [
"evenkeel"
]
}
}
}same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12
Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs
README
synced Oct 26, 2026evenkeel
A FastAPI backend template that keeps its claims checkable.
Each badge links to the file that makes it true, and the first one is the job that keeps the rest honest — the layer contract, the strict type check and the coverage floor all run in that pipeline.
Clean architecture, ports and adapters, and an opt-in slice system — where every architectural promise is enforced by a machine rather than described in this file. The layer rules are a CI job. The null adapters are held to the real adapters' contract by a shared test suite. Readiness is a query, not a constant.
It runs with one command and no credentials. Every optional dependency it has — Redis for the lock, limiter, idempotency store and concurrency cap; Prometheus; the outbound risk provider; the identity provider — ships a null adapter, so the core is a working API on its own and scaling up is a swap in one provider method rather than a rewrite.
docker compose up
Or without installing anything: Code → Codespaces → Create codespace. The devcontainer brings up Postgres, installs the environment and applies the migrations, so the first thing that happens is a working stack rather than a dependency error.
Nothing in that recording is staged. It is one VHS tape driven against the same image CI boots in its smoke job, and re-recording it is two commands — which is the point: a README that can be re-run cannot quietly drift from the code.
Why another template
Most FastAPI templates show you how to wire a framework. This one encodes the things that only show up after a service has been in production for a while:
| Concern | What the template does |
|---|---|
| Readiness | /ready actually checks its dependencies and returns 503 when they are down |
| Money | Decimal everywhere, arithmetic across currencies raises |
| Concurrency | distributed lock and optimistic version — each covers what the other cannot |
| Retries | Idempotency-Key is a first-class port, not a per-endpoint hack |
| Timezones | one TypeDecorator at the column boundary, so naive datetimes cannot enter |
| Errors | RFC 9457 Problem Details from a single handler, status code declared on the error class |
| Metrics | route templates as labels, never raw paths — the standard way to melt Prometheus |
| Secrets | redaction lives in the log pipeline, not at call sites — with the records it does not cover written down rather than glossed over |
| Outbound calls | timeout, budget, concurrency cap, jittered retry, size cap — with the cost of each one measured under load |
| Architecture | layering is enforced by import-linter in CI, not by a README promise |
Architecture
flowchart TD
HTTP[presentation/http] --> APP[application]
MCP[presentation/mcp] --> APP
CLI[entrypoints] --> APP
APP --> DOM[domain]
INFRA[infrastructure/adapters] -.implements.-> PORTS[application/ports]
APP --> PORTS
SETUP[setup/ioc] -.wires.-> INFRA
SETUP -.wires.-> APP
Dependencies point one way: presentation → application → domain. Infrastructure
depends on the ports it implements and nothing depends on infrastructure except the
composition root. The domain imports nothing but the standard library.
A request walks the layers like this:
- Router parses the request and calls one interactor. No business rules.
- Interactor owns policy — rate limit, transaction boundary, metrics — and delegates.
- Domain enforces invariants. A wallet cannot go negative because
Wallet.withdrawsays so, not because an endpoint remembered to check. - Adapters translate rows to entities and back.
The example domain
A wallet ledger: open a wallet, deposit, withdraw, list entries. It is deliberately not a to-do list — money forces the template to demonstrate invariants, concurrency, idempotency and auditability instead of describing them.
POST /v1/wallets open a wallet
GET /v1/wallets list (cursor-paginated)
GET /v1/wallets/{id} read one
POST /v1/wallets/{id}/deposits credit (accepts Idempotency-Key)
POST /v1/wallets/{id}/withdrawals debit (accepts Idempotency-Key)
GET /v1/wallets/{id}/entries ledger history
GET /health /ready /version operations
GET /metrics Prometheus, off unless enabled
Every failure is an RFC 9457
application/problem+json document, and every status the API can return is
declared in the schema — including the 403, the 409s, the 429 and the 503 that
most generated specs omit. Branch on code, never on title.
Calling other services
The one outbound dependency — risk assessment before a balance changes — is the
reference for any that follow. It ships with a null adapter (allow-all), so
none of it is in the way until you point APP__RISK__PROVIDER=http at a URL.
Failures are values, not exceptions: a timeout, a refused connection, a 500, a
malformed body and a full bulkhead all arrive as
RiskOutcome.UNAVAILABLE, and the use case decides what that means. Refused and
unavailable are never merged — one is the provider working, the other is the
provider missing.
The guards, and what each one covers:
| bulkhead, refusing rather than queueing | in-flight calls are bounded, and a caller who cannot be served is refused in under a millisecond instead of paying the provider's latency first |
| per-attempt timeout and an overall budget | the worst case is a number someone chose |
retries with full jitter, honouring Retry-After | only where a retry can succeed; never a 400, never a malformed 200 |
| response size cap | a broken upstream cannot spend this process's memory |
docker compose --profile load up starts a stub provider with dials for
latency, failure rate and refusal rate, and tools/load/wallets.js drives it
under k6. The measurements — including the one that contradicted a docstring in
this repository — are in
tools/load/README.md, and the reasoning is
ADR 6.
Seeing which guard fired
Every guard above answers the client with the same 503. The dashboard is where
they stop looking alike.
Read the seconds on that picture as a property of the stub, not of this service. The run behind it points the API at a provider configured to stall for 700ms with a concurrency cap of 32, then offers it 200 writes a second — about ten times what that path can complete. Nine calls in ten are shed immediately and the rest sit against the retry budget, which is what the tail in the latency panel is. The same code with no provider in the way serves the same load in tens of milliseconds. Both runs, and the caveats that make either number nearly meaningless on a laptop, are in tools/load/README.md.
The picture exists because a degraded dependency is the only condition under which the middle row has anything to say.
make observe # api, postgres, the stalling stub, prometheus, grafana (:3000)
make load # 200 writes/s for 30s against the stalling provider
make down # stop all of it, overlay and volumes included
make dashboard-image regenerates it, with the render profile up:
docker compose -f compose.yml -f compose.observability.yml --profile load --profile render up -d --wait
make dashboard-image
Eight alert rules sit alongside it in
ops/prometheus/alerts.yml, under one constraint:
none of them may fire on an idle service. Every ratio carries a minimum
volume gate, because 0/0 is not the problem — one request, one failure and a
ratio of 1.0 is. An alert that pages on a healthy
instance is one everyone learns to ignore, and it takes the rest of the file
with it.
The dashboard is checked against the code by
tests/unit/test_observability_stack.py: every panel and every alert must name
a metric the adapter actually emits, and no scrape label may shadow one the
application sets. That last test exists because service: evenkeel on the
target looked like harmless decoration and quietly replaced the label naming the
dependency — so by (service) grouped by a constant and drew a single line
labelled "evenkeel" however many providers there were. The panels still
rendered.
A second transport, over the same use cases
presentation/mcp exposes the wallet as MCP
tools, so a model can hold an account. Six tools, each resolving the same
interactor the HTTP router resolves:
APP__MCP__OWNER_ID=<uuid> make run-mcp
Nothing below presentation changed to add it — no new port, no argument on a
command, no branch in a use case. The idempotency key, the per-wallet lock, the
optimistic version, the rate limit and the outbound risk check all apply to a
tool call because none of them ever lived in the HTTP layer. That is the only
honest way to show the layering is real rather than decorative, and it is why
this exists.
The owner is configuration, never a tool argument. A
deposit(owner_id, ...) tool would be a cross-tenant IDOR with a
natural-language interface: the model reads untrusted text for a living, so
"it was persuaded to pass a different id" is a normal Tuesday. The parameter
that does not exist cannot be manipulated, and a test asserts on the published
schemas that none of them has one.
Adding this surfaced three defects that one transport had been hiding — an
amount larger than the ledger column can store, Money(NaN) raising the wrong
error, and test fakes that disagreed with the database about row order. All
three are fixed in the domain and the fakes, not in the tool layer.
ADR 7 has the reasoning.
API reference
Three renderings of the same document, on a local run only:
/scalar | search, a working request client, dark mode |
/docs | Swagger UI |
/redoc | ReDoc |
make docs builds the same reference as a static page — the deploy refuses to
publish a document that disagrees with the application, so a page that ships is
also proof the spec is current. The CDN script is version-pinned with an
integrity hash, checked against the file actually served by
tests/unit/test_docs_page.py.
openapi.json is committed. CI regenerates it and fails if it drifts — the same
treatment migrations get — and runs oasdiff
on pull requests so a breaking change to the contract is a red check rather than
a surprise for whoever built against it.
How the tests are organised
Not a pyramid. Two questions, asked separately:
| Directory | Question it answers | Needs |
|---|---|---|
tests/unit | Do the domain rules and the use-case policy hold? | nothing |
tests/contracts | Does every adapter of a port behave the same? | Redis, optionally |
tests/http | Does the application behave correctly end to end? | nothing |
tests/integration | Does the SQL do what the fake pretends it does? | PostgreSQL |
The split that matters is not unit-versus-integration but architectural part
versus application as a black box. unit and contracts test parts; http
and integration test the thing itself. Testcontainers made the second half
cheap enough that pushing everything into mocks now buys speed by giving up the
only tests that would have caught a broken adapter.
make test runs everything that needs no service; make test-integration runs
the rest.
Security, with receipts
docs/SECURITY_CONTROLS.md is 51 controls, one row
each: the claim, where it is enforced, and the test that proves it, tagged with
its CWE. docs/THREAT_MODEL.md is the reasoning behind
them — assets, trust boundaries, and a section on what is deliberately not
defended, because a threat model listing only wins is marketing.
The two are one argument split in half, so they are checked against each other:
every test the matrix names must exist and carry the CWE assigned to it, every
@pytest.mark.cwe test must be cited, and every CWE in the threat model must
have a row. That check earned itself immediately — the matrix was citing a test
renamed some commits earlier, a receipt pointing at nothing.
uv run pytest -m cwe # every test that is somebody's receipt
For agents
AGENTS.md is the rule set an agent gets before it edits anything:
the layer table, a "task → files, in order" map, DO/DON'T for the rules with
teeth, an anti-pattern table of every trap this build actually hit, and the list
of changes that must stop and ask a human. CLAUDE.md points at it, so both
conventions resolve to one file.
Most of those rules are a command rather than a paragraph — make arch fails on
a crossed layer, make types on an adapter that drifted from its port,
make schema-check on an HTTP contract that changed without the document
changing with it. Two conventions became checks while this was written: every
adapter must be named in conformance.py
(tests/unit/test_adapter_conformance.py), and the scaffolder's own output must
pass the project's lint and resolve every symbol it imports
(tests/unit/test_new_vertical.py).
make new-vertical NAME=orders
writes the interactor, schema, router and test in the right layers and prints the three edits it deliberately does not make for you.
Development
make on its own prints the grouped, coloured list; the whole surface is
twenty targets in five groups.
make sync # install
make check # every gate, with a pass/fail summary and per-gate timings
make run # start the API
make run-mcp # start the MCP server on stdio
make demo # re-record docs/demo.gif from the tape
make load # drive the API under k6
make check runs the same list CI does and, like the CI gate job, runs every
gate even after one fails — being told about one broken thing at a time costs a
full rerun to find the next. Colour follows NO_COLOR and
switches itself off when the output is not a terminal, so a CI log does not fill
with escape codes.
CONTRIBUTING.md is what a change has to clear to land here — a test you have watched fail, a receipt for every claim, and a number that comes from a command. Vulnerabilities go through SECURITY.md rather than an issue.
Status
In place: the core and the wallet slice, the CI pipeline the badge points at,
the outbound-call slice with its load runs, Prometheus metrics behind
/metrics with a Grafana dashboard and alert rules over them, the MCP
transport, JWT/JWKS verification against any OIDC issuer, and the OpenAPI
contract with its drift and breaking-change checks.
Not started, and deliberately not listed above as if they were: tracing, an
outbox, a worker. The reasoning behind what is here
lives in docs/adr/.
License
MIT