Who this is for: whoever is about to let somebody else's client reach this
instance — a colleague's editor, a Product Owner's desktop app, a CI job, a
hosted chat connector. You need: an instance whose revetly.toml declares
[auth], which revetly init --auth writes. Not here: the keys themselves
(engine-config-surface.md is the reference), and
what a caller may read once they are in — grants narrow it, and what happens
at the read surface is multi-project.md.
Over stdio and on loopback this engine answers whoever can already reach the
machine it runs on, and needs none of this. The moment it binds any other
address it refuses to start without [auth], because an unauthenticated engine
on a routable address fails silently: a reader who should never have connected
gets ordinary, correct, complete answers back, and nothing anywhere records
that anything went wrong.
This engine does not issue tokens and will not. An engine that issued them would hold credentials, run consent flows and become the service a team has to migrate away from later. So it validates — signature, issuer, expiry, audience — and the authorization server is somebody's deployment, not this repository's.
revetly init --auth writes two files, and running an authorization server is
neither of them:
[auth] in revetly.toml — the issuer this endpoint trusts, what it calls
itself, what a token's aud must carry, and the name of the claim groups
arrive in;revetly supervise, starts the endpoint because [auth] is declared, so
starting it is docker compose up -d and keeping it running is the
supervisor's restart plus the restart policy the rest of the stack already
declares.No service, no volume, no admin credential and no realm file enters your
instance. What this engine needs is a declaration: with [auth] declared
and the issuer switched off entirely the engine starts fine — the key set is
fetched lazily, and nothing has needed a key yet. Where an issuer runs, and
whether you run one at all, is a deployment choice and yours.
If you have one already — Okta, Entra, Google Workspace, a Keycloak your
platform team operates — point [auth] at it and read the contract
below. If you have none, --issuer-kit writes a
runnable one somewhere outside this repository:
revetly init --auth --issuer-kit ../acme-issuer # a stack of its own, outside this one
cd ../acme-issuer && docker compose up -d # the issuer
open http://localhost:8081/admin # admin / REVETLY_ISSUER_ADMIN_PASSWORD in its .env
# add a user in the `revetly` realm, and put them in the `engineering` group
cd - && docker compose up -d # the graph and the engine's endpoint
revetly doctor # discovery, keys, grants, registration, endpoint
revetly doctor --token "$TOKEN" # would this token get in, and as whom
The kit shares no compose file, no network and no override with this instance's stack, or with whatever fronts it. It publishes a port on the host and everything reaches it that way. Stop the front end and the issuer still authenticates; stop the issuer and the engine still serves on loopback. It is a reference implementation of the contract below and never a dependency — delete the directory the day you adopt somebody else's issuer, and nothing here notices.
revetly serve --http run by hand inside the container is a process no restart
policy reaches, no docker compose up restores, and nothing records the death
of. The stack comes back after a reboot and the endpoint does not — and the
person this whole page is for has no shell here, so their entire diagnostic
surface is the endpoint answering.
So it is a child of the engine service's supervisor, and not a container of
its own: the lock that serialises ingest passes is held by the processes inside
one container and by nothing outside it, so a second container serving MCP
would be a second authority running the first one's passes. revetly supervise, the image's command, starts the endpoint wherever [auth] or
[served] is declared, beside the configuration page and any watch loop, and
restarts it if it exits. docker compose exec revetly revetly ... still
reaches the CLI and the stdio entry point exactly as before.
What --auth puts in the compose file, under the revetly service, is the
port and no command:
ports:
- "127.0.0.1:8722:8722"
- "127.0.0.1:9714:9714"
The second entry is the configuration page's. A command: on that service
replaces revetly supervise, and with it the endpoint, the page and every
watch loop. An instance an older init --auth gave
command: ["revetly", "serve", "--http", …] still serves; delete that line and
recreate the container to get the page and the watch loops back.
The endpoint binds every interface inside the container, on the port and path
[auth] resource names; what anything outside reaches
is the ports: line, and that line follows [auth] resource — a resource only
this machine can reach is published on 127.0.0.1, one that names anywhere else
is published where callers are. The two move together because the RFC 9728
document hands a client the declared resource: a client that follows the wrong
one asks its issuer for a token this endpoint then refuses, with nothing naming
why. Re-running revetly init --auth adds the port to a compose file Revetly
wrote, once and never twice, and prints it for one you own.
An address this instance cannot authenticate refuses to start rather than answering a stranger in full. Supervised, that is a child retried on a backoff that refuses again — which is what the doctor line below is for.
The moment anything stands in front of the endpoint — a tunnel, a reverse proxy, a load balancer, an ingress — a client is handed one address and this engine binds another. There are three states and not two:
An instance that declares [auth] has already named the first two: resource
is the address the discovery document hands a client, and doctor checks it
against what the compose publishes. A fronted instance needs somewhere to say
so, because https://revetly.example.com/mcp names no port at all — and a
check comparing ports skips silently exactly where the two halves are most
likely to disagree.
[served]
public_url = "https://revetly.example.com/mcp"
Declare it only where the two genuinely differ, or where there is no [auth]
to carry it — an engine started by hand with revetly serve --http on the
host, bound to loopback and reached through something beside it. Undeclared,
the public address is [auth] resource and nothing changes.
Inside the engine container that last case does not exist. [served] makes
revetly supervise start the endpoint on 0.0.0.0, the only address a
published port reaches, and an endpoint bound anywhere but loopback refuses to
start without [auth]. The supervisor retries it on its backoff and it
refuses again, so a container instance that serves declares [auth].
A client living in a browser is the one client this endpoint cannot serve on
the strength of the address alone. Its session identifier arrives as a
response header — mcp-session-id — and a browser hands no header to page
script that the server has not named as readable. So a browser client
initializes, cannot read its own session id, and every call after that arrives
sessionless: it connects, reports success, and behaves as though it never did.
Name the origins that hold one:
[served]
allowed_origins = ["https://claude.ai"]
An origin is a scheme, a host and a port — never a path, and never *, which
is refused by name because it would hand any page on the internet a
cross-origin path to this corpus. The endpoint's own public origin is permitted
alongside whatever is written here without being written twice: a page served
from the address this endpoint answers on was never cross-origin with it.
With one or more declared, the MCP route answers the cross-origin preflight,
names the session header as readable, and refuses any other origin with a body
that says so — because a browser reports a rejected preflight to page script as
a network failure, indistinguishable from a dead endpoint. Undeclared, the route
emits no CORS header at all, which is what it did before this key existed and is
also the right answer where a front end adds its own: two
Access-Control-Allow-Origin headers is a hard failure in every browser, and
this engine cannot see what is in front of it. Declare it here or there, never
both.
Non-browser clients are untouched either way. They send no Origin header, and
a policy written for browsers is applied to none of them.
No front end ships with this engine. revetly init writes no proxy, no
tunnel, no ingress; no vendor appears in the source or in the image; and no
check here starts, configures or repairs anything in front of the endpoint — a
check that creates what it was asked to verify has verified nothing. Which one
you run is yours. What it has to satisfy is not.
Seven requirements, each of them a way to break an MCP client that then looks like this engine's fault. The first five are about what a front end changes on the way through; the last two are about it answering at all. A front end that adjudicates a request rather than forwarding it has replaced this engine for that request, and nothing in what the client receives says so.
/mcp hands a client HTML where the protocol requires a 401 with
a challenge. A client cannot log in through a page; it follows the challenge.WWW-Authenticate passed through unmodified. That header carries the
pointer to the RFC 9728 document, which is how a client that has not been
told out of band finds where to authenticate. Stripping it removes discovery.OPTIONS carries no
Authorization header — it cannot, there is no session yet — so a front end
that requires one fails every browser-based client before it starts. The
engine answers that preflight itself where [served] allowed_origins names
an origin, above its own bearer check; a front end has to pass OPTIONS
through and, if it does, must not add CORS headers of its own on top.iss it mints. A
token's iss is compared character for character by the client that has to
present it.KC_HOSTNAME in the compose file.…/mcp. for …/mcp. The client then gets a bare 404, has no
protected-resource document to follow, and does what the specification tells
it to do without one — it treats the MCP origin as
its own authorization server and sends a browser to /authorize there.
One character, and every
symptom names the engine. There is nothing behind the endpoint's hostname but
this engine, so there is nothing for a front end to protect by adjudicating
paths; scope the issuer's hostname, where an admin console lives.User-Agent refuses a client for the HTTP library it was written
with. A valid MCP client holding a valid token is turned away, with no
WWW-Authenticate and no error body, while a client holding no token at all
gets through because it happens to be written with a library on the good
list. The token is what decides who may read this corpus. Turn signature
filtering off for the paths under the endpoint — and for the issuer's,
since a client refused at the token exchange fails one layer further from
anything the operator is looking at.Out of scope here and not out of yours: TLS, certificates and DNS belong to whoever runs the front end.
The same rule, pointed at the authorization server. This engine ships one
implementation of it — revetly init --issuer-kit — and validates against any
of them. Six requirements, and none of them names a vendor:
GET <issuer>/.well-known/openid-configuration, or the OAuth 2.0 well-known path
beside it, answering JSON. Everything else here is read out of it.iss its tokens carry. The document's own
issuer field, the address you fetched it from, and [auth] issuer are one
string. An authorization server never told its own public name publishes the
loopback address it sees itself on — and then mints tokens this engine
refuses, one at a time, while every other check passes. revetly doctor
compares all three and names which is wrong.jwks_uri the document names.
The jwt verifier checks signatures locally against it; introspection
asks the server on every request and needs no keys, at the cost of a round trip per
call.[auth] audience
— or [auth] resource where you declare no separate audience — in aud.
Without it, every token that server ever mints for anything would verify
here, which is RFC 8707's confused deputy and why jwt refuses a token with
no aud at all.[auth] groups_claim
states, so a grant has something to match. What is in it is the issuer's
business; that it is there is this engine's.claude.ai, the host its
proven row in the client table names. Claude Code in the terminal needs none
of it. The allowlist is declared once here, as [auth] registration_hosts, and enforced wherever the issuer runs. Doctor checks it
by registering a client at a declared host, refusing one at a host nothing
declares, and taking both back through RFC 7592 — by behaviour, never by
reading a file. The client it registers is shaped the way real ones are: it
asks for the openid scope and declares the URLs a client declares about
itself. A probe that sends less asks an easier question than the
caller does, and passes where the caller fails.Nothing on that list is Keycloak's, and nothing on it is optional except the sixth. An issuer that satisfies it passes the same doctor chain the kit does, covered by the same tests.
revetly doctor carries a served endpoint line on every instance. Healthy,
with the stack up:
[PASS] served endpoint clients reach http://localhost:8722/mcp ([auth]
resource): loopback — a caller that is already on this machine;
published on 127.0.0.1:8722 (this host only, declared and not
confirmed) by docker-compose.yml; answers at
http://127.0.0.1:8722/.well-known/oauth-protected-resource/mcp, naming
resource http://localhost:8722/mcp; clients are sent to
http://localhost:8081/realms/revetly. Asked over loopback from inside
this container, so it says the process is up and says nothing about
whether a caller elsewhere reaches it — that is the published bind above
and the network in front of it. What the file asks for: docker-compose.yml
publishes 127.0.0.1:8722:8722. Nothing here confirms what is published:
this engine holds no Docker socket (/var/run/docker.sock is not one
here), so the daemon cannot be asked, and a compose override, a `-f`, a
`COMPOSE_FILE` or a plain `docker run -p` moves the mapping without
changing the file this read.
The reach in brackets is what this engine can say about who opens that port,
and on an ordinary instance nothing here measured it: the engine service holds
no Docker socket on purpose, so the compose file is the whole of what it read
and the line says which. Mount one into that service and the line names the
daemon it asked and reports what that daemon publishes. Two files in this root
publishing 8722 on two addresses is a warning of its own: docker compose
merges an override without being asked, so which of the two the stack came up
on is not visible from in here.
On an instance that serves stdio only it is a SKIP naming the reason, so you
learn the endpoint is off rather than learning nothing. It warns — and never
fails, because a dead endpoint stops no ingest, capture or review — when:
[auth] is declared and the compose file runs no endpoint, or an endpoint is
declared on a routable address and [auth] is absent or incomplete: the pair
is written together, and a hand edit moves one;[auth] resource names are different
addresses;revetly.toml — a process running an older declaration than the file holds,
and a restart of the revetly service is the fix;403, a 404 or a 5xx at the public
address carrying neither an MCP error body nor a WWW-Authenticate challenge.
This engine emits no refusal without one of those, so a reply with neither was
written in front of it and the request never arrived. That is the opposite
fact from silence and is reported apart from it: read as "the endpoint is
down" it sends an operator to restart a service that never saw the request;user-agent apart. A difference is something between here and there
deciding on the header rather than on the token. The line names both
signatures and which was refused, and the fix names the filtering to turn off.
What product does it, and what to click, is yours: this engine does not know
what is in front of it and must not learn;[auth] at all — the engine refuses
a routable bind without one, and a front end reaching a loopback bind goes
around that refusal.The line reports the pair on every fronted instance: the address clients reach, the port this stack publishes, and which of the two each probe reached. The mapping between them is in front of the engine, which cannot see it — so the check for a fronted instance is asking the public address, not comparing a port a client never connects to.
Every line about the public address says where it was asked from, on the
answers as much as on the silences. Doctor cannot leave the container, and a
front end that treats its own origin as a neighbour answers a request from in
there more kindly than it answers a stranger's — which is exactly how the whole
chain read healthy while real clients were being refused. So an answer from
inside is reported as an answer from inside, and curl -sS <the public address>
from where a client actually is remains the stronger evidence.
None of it starts, configures or repairs anything in front of this engine. The check asks and never configures; what the fixes name is what an operator would change, in the words of what the front end is doing rather than of what it is.
Under --offline the request is not sent and the line says so. Whether the
stack comes back after a reboot stays the restart line's to report; this one
never repeats it.
The kit's realm carries the three settings the engine cannot fix from its side:
an audience mapper, so a token says which resource it was minted for; a group
membership mapper under the claim [auth] groups_claim names; and both on a
scope every client gets by default, so a client that registered itself
arrives with them. It carries no users — the first account on an
authorization server is the one part that has to be created by the person it
belongs to. It is imported on first start only, so changing it after the
volume exists changes nothing until that volume is rebuilt, or the same change
is made in the admin console.
Pointing this instance at Entra, Okta, Google Workspace or a Keycloak somebody
else runs is issuer, audience and groups_claim. Nothing else in the file
moves, and there is no issuer-specific code path anywhere in the engine to
follow it. Keeping the kit's issuer and federating an upstream identity
provider into it changes none of the three. Neither is a migration.
[auth]
mode = "oidc"
issuer = "https://login.microsoftonline.com/<tenant>/v2.0"
resource = "https://revetly.acme.com/mcp"
audience = "api://revetly"
groups_claim = "groups"
verifier = "jwt"
Instances scaffolded before this change have a keycloak: service, a
keycloak_data: volume, KEYCLOAK_* keys in .env and
keycloak/revetly-realm.json in the repository. Nothing breaks while they stay
there, and revetly init will not touch them. When you move:
revetly init --issuer-kit ../acme-issuer # the replacement stack
docker volume ls | grep keycloak # the volume you already have
Put that volume's name in REVETLY_ISSUER_VOLUME in the kit's .env before
starting it, and copy KEYCLOAK_ADMIN_PASSWORD across as
REVETLY_ISSUER_ADMIN_PASSWORD. A kit started on a fresh volume imports the
realm cleanly and comes up looking entirely correct, having lost every client
that registered itself through RFC 7591 and every session a human holds —
those live in the database, not in the realm file. Then remove the service, the
volume declaration and the KEYCLOAK_* keys from the instance. [auth] stays
exactly where it is: it is the declaration, and it is the only part the engine
ever read.
The identity provider owns who somebody is. This instance owns what they
may reach. That division is deliberate: no identity provider knows what a
group_id is, and a grant table living in one would put this engine's
disclosure policy somewhere its operator cannot audit.
[auth.grants.engineering] # a value of the groups claim
role = "developer"
projects = ["platform", "product"]
A grant can only narrow. The permitted set for a call is the intersection of
what the project's own imports/exports declare and what the caller's grants
name — config keeps its veto, and a token cannot open a namespace the project
never exported.
projects names namespaces in the vocabulary imports already uses — a project
or a shared namespace — and nothing is inherited: a grant naming platform
does not reach a shared namespace platform imports unless it names that too.
Name both when you mean both; a shared namespace is exported to several
projects, and inheriting one hands out reach nobody wrote down.
What that does at the read surface is multi-project.md, and
the short version is: a project outside the grant answers as a namespace that
does not exist rather than as a permission error, a caller who names none is
answered for their grant rather than for the instance default, and list_reviews
lists only their own projects' — a parked draft carries its title.
An endpoint started bound to one project (revetly serve --http --project NAME) narrows once more, and never widens. A caller whose grant does not reach
NAME is refused as for any project outside a grant, a grant of several
projects is narrowed to NAME, and a call naming another project is refused
with bound-to-another-project. A read still reaches what NAME imports
where the grant allows it.
A caller no grant names reaches nothing. That includes a static token
declaring no grant, and it is why the table is all-or-nothing: declare one and
every caller needs a line in it. An instance that declares no [auth.grants] at
all narrows nothing, and every authenticated caller reaches what config permits,
which is what every install did before grants existed.
revetly doctor reads the table both ways. A grant naming a project this
instance does not declare grants nothing; a project no grant names is a
namespace nobody can read, and its reads answer empty.
A caller whose grant reaches nothing is told that about their own token:
the read carries a grant key naming the claim they presented, the
values it carried, and that no entry here names them — so an empty answer is
legible as a fact about that token rather than as an instance holding nothing, a
pass that never ran, or a broken engine. It names no project, no count and no
roster. revetly doctor --token <bearer> reports the same fact, which is how
this is diagnosable before a client is pointed at the endpoint rather than only
from inside a session that already failed. It also says what each grant reaches after config's veto,
which is the number that decides a call and is not the one written in the file.
revetly init --auth writes this table naming the project the standing
declaration says exists — a kept revetly.toml decides, not the group that
run would have derived from a directory name. Where an instance declares
several projects it writes no projects line at all and says which ones are
there, because a grant naming the wrong namespace authenticates its caller into
a read that returns nothing, and nothing about that is distinguishable from an
empty corpus.
A token declared here by its hash. No browser, no issuer, and it still works when the authorization server is down — which is the hour a CI job most needs to reach the corpus.
[auth.tokens.ci]
hash = "sha256:<printf %s \"$TOKEN\" | shasum -a 256>"
expires = "2027-01-31"
grant = "engineering"
Mint the value with openssl rand -hex 32, hand that to the machine, and put
only its digest in the file — revetly.toml is committed, and a bearer token in
it is a credential in git history. The expiry is compulsory: a token with none
outlives whoever minted it, and this engine holds no revocation list to make up
for that.
It authenticates a machine, and every record it writes says so:
attribution_actor: machine beside the trust label, so a delivery a CI job
recorded never reads as one a person decided. Declaring tokens does not replace
the issuer — an instance in mode = "oidc" accepts both, and the declared
token is checked first because it costs a hash rather than a signature.
Access tokens are short — the shipped realm mints them for five minutes — and clients refresh them against the issuer. So removing somebody from a group, or disabling their account, takes effect at their next refresh rather than instantly. Up to that long, a token already in their client keeps working.
That window is the trade the jwt verifier makes for checking signatures
locally rather than calling the issuer on every request. Two settings close it:
verifier = "introspection" asks the issuer about every token, so a
revocation takes effect on the next call. It costs one network round trip per
request, and an issuer that is down then authenticates nobody.A static token has no refresh and no revocation list at all: it is live until its declared expiry, or until you take the line out of the file and restart.
Claude Code (the terminal CLI) runs on the machine you are on and needs none of what follows — loopback and a tunnel is enough.
A connector that runs in somebody else's cloud — claude.ai, ChatGPT, and a custom connector added in the Claude desktop app, which reaches the engine from claude.ai rather than from the desktop — needs all of it:
[auth] resource names;That last one has two ways to be wrong and they point opposite ways.
Registration that refuses the client you want is a chain with one closed link, and everything else about it reads as healthy. Say where a self- registering client may be:
[auth]
registration_hosts = ["claude.ai", "chatgpt.com", "chat.openai.com"]
Hosts and never URLs: what is pinned is the host a client says it will be
redirected back to, which is the half an operator can actually name — a
registration request itself arrives from a chat vendor's server fleet, whose
addresses are not yours to name and do not stay still. *.example.com covers a
domain.
And the key is named for half of what it governs. An allowlist of this kind is commonly read against every URL a client declares about itself — its homepage, its logo — and not only its redirect URIs. So a client whose every redirect URI is declared can still be refused, for advertising where it lives. Nothing here decides that; it is the issuer's policy. What is here is that the lines pointing at this key say so, because the key's name cannot.
Registration that refuses the client and takes a simpler one is the third way, and it is the quietest. Discovery answers, the keys are there, the endpoint is advertised, a probe client registers — and the client that has to connect is refused, for the scope it asks for or for a URL it declares about itself. Doctor now asks the caller's question, and where the answer is no it says which half by sending one variable at a time rather than by reading the refusal: the two messages a real server gives for them can differ by a single letter, which is no discriminator to build on. Nothing about that learns any authorization server's rules — the probe asks and reports, so an issuer with entirely different policies is answered by the same code.
The realm revetly init --auth writes carries that list to the issuer as its
own client-registration policy, and it declares the whole policy set rather
than leaving it to be inherited: a realm declaring none gets whatever the
authorization server's version defaults to, and one of those defaults refuses
every registration there is. The realm file is imported on a first start
only — so a server whose data volume already exists keeps what it imported
then, and a changed allowlist needs that volume rebuilt or the change made at
the issuer.
Registration that takes anybody is the hole. An open endpoint means whoever can reach it may create a client at your issuer. Shipped empty, the allowlist refuses everything, which is a server nobody can connect a hosted client to until somebody declares a host — deliberately, because the alternative is a product with particular chat vendors' hostnames baked into it.
revetly doctor tells the two apart by registering a client and taking it back
again through RFC 7592: one at a host you declared, which is the client that has
to work, and one at a host nothing declares, which is the question of whether
the allowlist bounds anything. It reports a refusal with the issuer's own words,
an open endpoint as a finding with its remedy, and neither as a failure — an
issuer that will not register a client stops no ingest, capture or read. Where a
probe client cannot be taken back, doctor names it by id rather than leaving it
behind quietly.
Getting an instance publicly reachable at all is deployment rather than engine
work, and which front end does it is yours. Declare the address it answers on
in [served] public_url, and the origins a browser holds a client at in
[served] allowed_origins, so doctor knows the state and can ask the question a
client asks — and satisfy the seven requirements above, which are what this
engine has to say about it and the whole of what it has to say.
revetly doctor prints the exact registration_hosts and allowed_origins
lines each proven client still needs, as the whole declaration after the
change. Add a teammate to a hosted instance walks
the whole path, from revetly init --auth to a second person's first read.