Skip to main content
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: in reversi.config.yaml, or ./manifests if that folder exists.
  • An explicit --mode or mode: always wins. Without one: observe when no manifests loaded, enforce otherwise.
  • The mode is read at start. After you add manifests, restart to leave the automatic observe mode.
On start the gateway lists the upstream’s tools and prints a classification banner on stderr. Output from a real run against the filesystem connector with no manifests:
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

  1. Manifest and scope. A tool with no manifest (in enforce mode), a draft manifest, or a permission outside the session’s scope is blocked.
  2. Schema checks (below), then policy, content checks, kill switch (policy, content checks).
  3. Approval if required: the call is blocked with the approval id, and the agent retries after an approver decides (approvals).
  4. Journal the intent (write-ahead), forward to the upstream, journal the outcome. The upstream result is returned unchanged. An isError result is journaled as failed; a transport error or timeout as uncertain.
A blocked call returns an MCP result with 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

With validate_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 carries schema_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 as uncertain (the upstream may still have acted).
  • retry repeats a failed or timed-out forward with exponential backoff. It is accepted only for read_only and idempotent tools; the manifest is rejected otherwise, and the gateway ignores retries for any other effect. Retrying a change could repeat it.
  • Defaults when retry is 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 a reloaded 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
Unknown fields are rejected, with every error reported at once. 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: false per upstream to keep names). Manifests use the exposed names. Two upstreams exposing the same name without namespacing is an error.
  • ${NAME} in args, env and headers expands 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/deny globs are compiled into policy rules, so they also show up as blocked records.
  • Egress check. Before connecting, every url upstream is checked against the project’s egress: rules (host allowlist, block_private, and cloud metadata addresses, which are always refused). A refused upstream stops the gateway at start with upstream <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 if listen.host is not loopback or if the role is admin. Default role operator; --console-role picks auditor or approver.
  • login: paste an API key or OIDC access token; needs auth.keys or auth.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 forwards tools/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.