Manual 0.8.0

Serving people who are not you

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.

The shape: the engine validates, the issuer is somewhere else

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:

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.

The endpoint is a service, not a command you type

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 address a client reaches is not the address this engine binds

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].

Where a browser may hold a client

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.

What a front end must satisfy

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.

  1. Nothing interstitial in front of the MCP path. An SSO login page in front of /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.
  2. 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.
  3. A CORS preflight answered without a bearer. 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.
  4. The issuer on a public origin of its own, matching the iss it mints. A token's iss is compared character for character by the client that has to present it.
  5. The issuer told its public name. An authorization server never told its own public name publishes the loopback address it sees itself on, and every client that reads its discovery document is then sent to a host it cannot resolve. For the shipped Keycloak that is KC_HOSTNAME in the compose file.
  6. Every path under the endpoint forwarded, rather than matched. Route the endpoint's hostname to the origin and let this engine answer — including the paths it does not serve. It answers those itself, with a body naming the transport it speaks and the paths it does serve, which is what tells a reader the request arrived. A front end that enumerates the paths it knows and 404s the rest looks identical until somebody mistypes a connector URL by one character: …/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.
  7. No filtering by client signature. Bot and security filtering that decides on the 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.

What an issuer must satisfy

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:

  1. A reachable discovery document. 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.
  2. A public origin matching the 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.
  3. A key set this engine can read, at the 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.
  4. The audience this endpoint pins. A token has to carry [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.
  5. A claim carrying group membership, under the name [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.
  6. RFC 7591 dynamic registration, gated by a redirect-URI allowlist — only if a hosted chat client has to connect. claude.ai, the Claude desktop app and ChatGPT register themselves and cannot be connected any other way — the desktop app's custom connector registers at 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.

What doctor says about it

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:

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.

Replacing the issuer is three lines

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"

If your instance still runs the issuer itself

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.

Who may read what: grants

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 machine: the static token

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.

Revocation has a window, and it is not zero

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:

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.

Reaching a cloud-hosted chat client

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:

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.