reversi-gateway is an MCP server that fronts one or more upstream MCP servers. Point your MCP client (Claude Code,
Cursor, VS Code, your own agent) at the gateway instead of the upstream. Every tools/call goes through the Reversi
engine before it is forwarded. The same program runs as reversi gateway ....
Observe first
You do not need manifests to start. With no manifests the gateway starts in observe mode unless you set a mode:- Manifests come from
--manifests,REVERSI_MANIFESTS,manifests:inreversi.config.yaml, or./manifestsif that folder exists. - An explicit
--modeormode:always wins. Without one: observe when no manifests loaded,enforceotherwise. - The mode is read at start. After you add manifests, restart to leave the automatic observe mode.
The same data is available any time as the
reversi.classification tool, and in the console under Tools and
Connectors. To turn the picture into manifests, run reversi suggest-manifest --upstream-cmd ... (or the
reversi.suggest_manifest tool), review the drafts, then publish them (manifests,
GitOps).
Modes
In
enforce mode a tool with no manifest is blocked. unclassified_reads: observe relaxes that for one case only: a tool
without a manifest whose server annotations say readOnlyHint: true runs, in a separate shadow observe run named
<run id>-reads, journaled with the note “unclassified read: the server says it is read-only; this is unverified”.
Unclassified changes stay blocked. The default is block.
What happens to a call
- Manifest and scope. A tool with no manifest (in enforce mode), a draft manifest, or a permission outside the session’s scope is blocked.
- Schema checks (below), then policy, content checks, kill switch (policy, content checks).
- Approval if required: the call is blocked with the approval id, and the agent retries after an approver decides (approvals).
- Journal the intent (write-ahead), forward to the upstream, journal the outcome. The upstream result is returned
unchanged. An
isErrorresult is journaled asfailed; a transport error or timeout asuncertain.
isError: true and Blocked by Reversi: <reasons> in the text, so the model can
read it. Compensation arguments ($result.*) resolve from structuredContent, or from a single text block holding JSON.
MCP has no field for “why”, and every ledger record needs one. Clients may send it in _meta["reversi/reason"]; otherwise the
record says MCP tools/call <tool>.
One session is one run, with the actor taken from the credential (or actor in the config when unauthenticated). A client
that reconnects can continue its run: over HTTP send Reversi-Run-Id: <id>, over stdio pass --run-id <id>. Only the
principal that opened a run may continue it.
Schema validation
Withvalidate_schemas: true (the default) the gateway compares each call with the schemas the upstream publishes in tools/list.
The checker covers
type, enum, const, required, properties, additionalProperties, items, numeric and string
bounds, pattern, multipleOf, allOf, anyOf and oneOf. It is a small checker, not a full JSON Schema validator:
$ref and if/then/else are not followed.
The MCP SDK’s own client would throw away a result that breaks the output schema even though the call took effect. The
gateway turns that client-side validation off on purpose and does the check itself, so a mismatch is recorded instead of
losing the result.
MCP annotations are hints
Servers may annotate tools (readOnlyHint, destructiveHint, idempotentHint, openWorldHint). Reversi reads them for the
banner and to seed draft manifests (reversi suggest-manifest, console import). They never grant anything: a draft
manifest is not enforceable until a person approves it, and an annotation alone never makes a mutating tool pass in
enforce mode (only unclassified_reads: observe lets a read-only claim through, and only as an unverified shadow run).
Schema drift
A manifest drafted from an upstream tool carriesschema_hash, a hash of the tool’s input schema. If the upstream later
changes that schema, calls are flagged with the tool's input schema changed since its manifest was approved: review the manifest again. That is a violation (blocked in enforce) until someone reviews the tool and publishes a manifest with the new hash.
Timeout and retry
Set per tool in its manifest:timeout_ms: the gateway stops waiting after this long and journals the step asuncertain(the upstream may still have acted).retryrepeats a failed or timed-out forward with exponential backoff. It is accepted only forread_onlyandidempotenttools; the manifest is rejected otherwise, and the gateway ignores retries for any other effect. Retrying a change could repeat it.- Defaults when
retryis given: 3 attempts, 200 ms base, 5 s cap (1 to 10 attempts).
Verification during rollback
A manifest can declare a read-back (compensation.verification) such as “call notes.read and expect restored: true”.
When the gateway executes a rollback it runs that read-back through the upstream (the verifying tool is an ordinary
upstream tool), retrying with backoff, and records verified, verification_failed or unverified. The same applies to
rollbacks started from the gateway console. See rollback.
Hot reload
Manifests, the policy and the manifest public key are re-read while the gateway runs. A configuration that fails to load is rejected and the previous one keeps serving. A successful change is written to the ledger as areloaded record.
The gateway’s own config file (upstreams,
listen, auth, allow, deny) is read at start; restart to change it. Mode is also fixed at start.
Config
gateway.yaml
Command-line flags (
reversi-gateway --help): --config, --manifests, --policy, --data, --store, --database-url,
--environment, --signing-key, --console, --console-role, and for one upstream without a config file
--upstream-cmd (+ repeatable --upstream-arg) or --upstream-url, --mode, --actor, --run-id. Everything else
(notifications, capture, egress, anchors, reload) comes from reversi.config.yaml, found in the current folder or above
(or REVERSI_CONFIG); see GitOps.
- Several upstreams are namespaced
<name>.<tool>by default (namespace: falseper upstream to keep names). Manifests use the exposed names. Two upstreams exposing the same name without namespacing is an error. ${NAME}inargs,envandheadersexpands from the environment, so secrets stay out of the file. A stdio upstream does not inherit the gateway’s environment, only what you list.allow/denyglobs are compiled into policy rules, so they also show up asblockedrecords.- Egress check. Before connecting, every
urlupstream is checked against the project’segress:rules (host allowlist,block_private, and cloud metadata addresses, which are always refused). A refused upstream stops the gateway at start withupstream <name>: <reason>. Stdio upstreams are not network destinations and are not checked.
Endpoints (HTTP mode)
A 401 carries
WWW-Authenticate: Bearer resource_metadata="…". Tokens must have the configured audience. Sessions belong to
one principal: using another’s session id is a 403. Host is validated against allowed_hosts (default: loopback only when bound to
loopback) and any Origin header is rejected unless allowed, which stops a web page driving a local gateway (DNS rebinding).
The gateway refuses to listen beyond loopback without authentication, and never serves the operator endpoint without it.
Idle MCP sessions are closed after 30 minutes.
Console on the gateway’s port
The console can be served by the gateway itself, on the same port and with the same off/open/login switch:--console open|login (or console: in the gateway config or reversi.config.yaml). It needs HTTP mode (listen).
open: no login, only requests addressed to a loopback host from the same origin. The gateway refuses to start iflisten.hostis not loopback or if the role isadmin. Default roleoperator;--console-rolepicksauditororapprover.login: paste an API key or OIDC access token; needsauth.keysorauth.oidc.- Rollbacks started in this console run the compensations on the upstream with the idempotency key, and verification read-backs go through the upstream too. The Connectors page lists each upstream with its health and how many tools have manifests. Manifest and policy editing works only when
manifests:/policy:point at a registry or policy-versions folder (GitOps).
reversi.* tools
On the agent endpoint (your own runs only):
On the operator endpoint, by role:
The agent cannot reach the operator tools: they are not on its endpoint, and calling one by name fails with “unknown tool”.
Compensations bypass
allow/deny and scope, since an operator is deliberately undoing something: keep permissions for
destructive compensation tools out of agents’ scopes.
Over stdio the gateway is a single unauthenticated agent session with the agent tools only. Operate it with the CLI against
the same data store (reversi approvals, reversi rollback), or run it in HTTP mode.
Storage and notifications
The gateway uses the same stores as the server (--store files|sqlite|postgres), so the console, CLI and gateway can all look
at one ledger and one approval queue. Approval and alert notifications, traces and anchors are
configured in reversi.config.yaml; REVERSI_APPROVAL_WEBHOOK still works. See storage.
Limits
The gateway journals and forwardstools/call. It does not proxy resources, prompts or sampling. tools/list results are cached
for five seconds. Rollback executes against the upstream the compensation tool is exposed by. The schema checker is a subset
of JSON Schema. The behaviour on this page was exercised against stdio fakes and the filesystem connector in tests and by
hand; no third-party hosted MCP server was used.
