# MCP server auth (deed-gated)

MCP authorization is optional, but when a remote [Model Context Protocol](https://modelcontextprotocol.io) server implements it, the spec's prescribed route is standing behind an OAuth 2.1 authorization server.
Grantor is "OAuth with no authorization server": you install `DeedGuard`, publish a discovery document, and agents authenticate with **deeds** instead of an OAuth flow.
No authorization-server process exists anywhere in your dependency graph.
If you haven't already, read [Concepts](concepts.md) for the mental model (deeds, pseudonymity, the on-chain trust anchor).

Every excerpt below is transcribed from the runnable reference example, starting with its server wiring.

### 1. Discovery + challenge — `grantorExpress`

```js
import { DeedVerifier, sessionJwt } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
import { grantorExpress, writeRejection } from "../../sdk/verify/ts/src/express.js";
import { DeedRejected } from "../../sdk/verify/ts/src/guard.js";
import { Registry } from "../../sdk/verify/ts/src/registry.js";

const verifier = new DeedVerifier(
  RPC_URL,
  Registry.devnet(REGISTRY), // this example runs against an anvil devnet — see Registry.canonical()/.dedicated() for a real deployment
  CHAIN_ID,
  TENANT_ID,
  AUDIENCE,
  ORIGIN,
  MAX_TTL_SECS,
  CACHE_TTL_SECS,
  ALLOW_INSECURE_ORIGIN, // false in production; true only for a localhost/dev ORIGIN
  Math.floor(Date.now() / 1000),
);

const g = grantorExpress({
  verifier,
  app,
  challengeEndpoint: "/auth/challenge",
  chainId: CHAIN_ID,
  // Fleet gating: `agent-zk` alongside `user-sig`. Nothing else in this file
  // changes — the guard dispatches on the deed's own `mode` field, so an
  // agent-zk proof and a user-sig wallet login are verified by the same
  // `/auth/token` route below. Membership in the tenant's on-chain registry
  // IS the authorization for that mode; there is no separate allow/deny list
  // here.
  modes: ["user-sig", "agent-zk"],
  vouchSignature: VOUCH_SIGNATURE,
  vouchEpoch: VOUCH_EPOCH,
  vouchExp: VOUCH_EXP,
});
app.get("/auth/challenge", g.challenge);
```

(The imports above are the example's relative paths; from your installed SDK,
import the same symbols from `@grantor/verify`.)

Passing `app` plus `challengeEndpoint`/`chainId`/`modes`/`vouchSignature`/
`vouchEpoch`/`vouchExp` makes `grantorExpress` auto-register `GET
/.well-known/grantor-deed` and self-check the document at startup — the
offline half of the origin-vouch check (see [Errors](errors.md)). A
misconfigured vouch fails the `grantorExpress(...)` call itself, not the
first agent's login.

## Before you run it

1. **A tenant on the registry** — `createTenant` plus funding on
   `GrantorRegistry`. No signup, no account. See [Getting
   started](getting-started.md).
2. **A signed origin vouch** for wherever your MCP server runs. It is
   **required** in the discovery document — `parse_discovery` rejects one
   without it — and it is what lets an agent's `authenticate()` call refuse
   a hostile origin *before* signing anything. See [Sovereign tier § Origin
   provenance](../sovereign-tier.md#origin-provenance).
3. **The deed guard**, in your server's language. This guide shows
   TypeScript; the guard ships in all four languages — see
   [Every capability, every language](../sovereign-tier.md#every-capability-every-language).
4. **`ALLOW_INSECURE_ORIGIN` left unset (or `false`) for anything but
   localhost.** Both the verifier construction above and the agent's `mintDeed`
   call take it, default-secure — set it `true` only while `ORIGIN` is a
   `localhost`/dev origin. A real deployment runs with it unset so a non-HTTPS
   or non-routable `ORIGIN` fails loudly instead of serving insecure logins.
   See [Sovereign tier § Fail-closed origin
   policy](../sovereign-tier.md#fail-closed-origin-policy).

## The three endpoints

The reference server wires exactly three routes around the shipped guard,
plus the MCP transport itself. The first — discovery and the challenge
endpoint — is shown above; the other two follow. (Fleet-gating agents with
`agent-zk` alongside `user-sig` is covered separately in [The fleet side —
`agent-zk`](#the-fleet-side-agent-zk).)

### 2. Deed → local bearer — `POST /auth/token`

An MCP client is not a browser fetching a protected resource with
`X-Grantor-Deed` on every request — it exchanges a deed once, for a
session bearer, the way a token endpoint would. So this route calls the
guard's verify step directly instead of using the header-based
`g.protect` middleware [Verify a deed](verify-tokens.md) documents. It also
reads the deed's own envelope `mode` and, if `REQUIRE_MODE` is set, gates on
it before verifying — see [Both modes at once](#both-modes-at-once) below
for why that check has to live here, not in the guard:

```js
app.post("/auth/token", async (req, res) => {
  try {
    const { deed, challenge } = req.body ?? {};
    if (!deed || !challenge) {
      res.status(400).json({ error: "MissingField", error_description: "deed and challenge are required" });
      return;
    }
    const deedJson = typeof deed === "string" ? deed : JSON.stringify(deed);
    // The mode THIS deed declares, read from its own wire envelope — the
    // only place it is available (see REQUIRE_MODE's doc above). A parse
    // failure here is not fatal: `g.guard.verify` below still runs and gives
    // the caller a real `BadDeedEncoding`/`BadProof`-style rejection instead
    // of a misleading mode error.
    let mode;
    try {
      mode = JSON.parse(deedJson)?.mode;
    } catch {
      // fall through — verify() below rejects the malformed JSON properly.
    }
    if (REQUIRE_MODE && mode !== REQUIRE_MODE) {
      res.status(403).set("Cache-Control", "no-store").json({
        error: "ModeNotAllowed",
        error_description:
          `this server only accepts ${REQUIRE_MODE} deeds at /auth/token ` +
          `(got ${mode ?? "unknown"}) — REQUIRE_MODE=${REQUIRE_MODE} is set`,
      });
      return;
    }
    // All crypto/replay/billing verification is the guard's job and covers
    // whichever mode the envelope declared — the REQUIRE_MODE check above
    // only decides whether that mode is ALLOWED here, not whether the deed
    // is VALID.
    const claims = await g.guard.verify(deedJson, challenge);
    // Belt-and-braces: the envelope said one thing, verification proved it.
    // A mismatch here would mean the guard verified a different mode than
    // the envelope declared — impossible by construction, worth crashing on.
    if (mode && claims.mode !== mode) throw new Error(`mode mismatch: envelope=${mode} verified=${claims.mode}`);
    const { token, exp } = mintBearer(claims.sub, claims.mode);
    // Two artifacts, two honest jobs. `access_token` is THIS demo's own
    // session: an opaque random bearer, checked against the in-memory
    // `bearers` map below (`requireBearer`) — real for this process, gone on
    // restart, meaningless to anything else. `session_jwt` is the "take this
    // into the rest of YOUR stack" artifact: a real, standard ES256 JWT,
    // minted with THIS server's own key via the shipped `sessionJwt`
    // convenience (`sdk/verify/ts`) from the claims the guard just verified.
    // Nothing here is Grantor-proprietary — any JOSE library can verify it
    // against `SESSION_PUBLIC_JWK` (or `GET /auth/session-jwks` below)
    // without ever importing this SDK. This example still gates `/mcp` with
    // the bearer map, not the JWT, so both patterns are demonstrated side by
    // side rather than one silently replacing the other.
    const sessionToken = sessionJwt(
      claims.sub,
      AUDIENCE,
      BigInt(TENANT_ID),
      SESSION_SIGNING_KEY_PEM,
      BigInt(Math.floor(Date.now() / 1000)),
      BigInt(SESSION_JWT_TTL_SECS),
      { iss: ORIGIN, kid: SESSION_KID },
    );
    res
      .set("Cache-Control", "no-store")
      .json({
        access_token: token, token_type: "bearer",
        expires_at: Math.floor(exp / 1000), sub: claims.sub, mode: claims.mode,
        session_jwt: sessionToken,
      });
  } catch (e) {
    if (e instanceof DeedRejected) {
      res.set("Cache-Control", "no-store");
      writeRejection(res, e, { realm: ORIGIN });
      return;
    }
    console.error("auth/token error:", e);
    res.status(500).json({ error: "InternalError" });
  }
});
```

**Every rejection here goes through the funnel** (`writeRejection`, the same
helper `g.protect`/`g.protectCapability` use internally, called by hand
because this route calls `g.guard.verify` directly). A 401 (the deed failed
to verify) gets a `WWW-Authenticate: Grantor-Deed realm="...",
discovery="/.well-known/grantor-deed"` header plus `discovery`/`learn` fields
in the JSON body, pointing a rejected caller at this server's discovery
document and the onboarding manifest. A 503
(`Chain`/`QuorumDivergence`/`WrongChain`/`LicenseExpired` — an RP/network
problem, not the credential) gets neither, since re-authenticating fixes
nothing. It is opt-out: pass `funnelHints: false` to
`writeRejection`/`grantorExpress` to keep the pre-funnel
`{error, error_description}` shape only. `ModeNotAllowed` stays hand-rolled —
it is this route's own policy, not a rejection the guard raised.

`mintBearer` (a random 192-bit token in an in-memory map — the RP's own code,
not the SDK) is the RP-owned session step: minting a session is deliberately
**not** the guard's job, the rule [Verify a deed](verify-tokens.md) states for
any deed integration. The bearer's lifetime is unrelated to the deed's `exp` —
the deed authenticates a login, the bearer is this server's session. The
response echoes back `mode` so a caller knows which credential kind it used;
`REQUIRE_MODE` unset means the route accepts whichever modes the server
advertises (see [Both modes at once](#both-modes-at-once)).

It also carries `session_jwt` — the RP's own standard ES256 JWT, minted with
the one-call convenience [Verify a deed § Or one
call](verify-tokens.md#or-one-call) documents. Both artifacts ship together so
the "roll your own session" and "one-call convenience" paths are demonstrated
side by side.

### 3. The MCP transport — bearer-gated

Past this point it is standard MCP (the official SDK's
`StreamableHTTPServerTransport`); the deed only decides who is allowed to
open it — and, since `bearers` now stores `mode` alongside `sub` (the
`/auth/token` excerpt above), `mode` rides through to the tool layer too:

```js
function requireBearer(req, res, next) {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : null;
  // `Map.get` here is a hash lookup, not a constant-time compare — acceptable
  // for this token specifically because it is 192 bits of `randomBytes`
  // (mintBearer above), so there is nothing a timing side-channel narrows
  // down to a feasible guess. A lower-entropy or structured secret (an API
  // key with a checkable prefix, say) would need `crypto.timingSafeEqual`
  // instead; copy that if you copy this file for such a token.
  const entry = token ? bearers.get(token) : undefined;
  if (!entry || entry.exp <= Date.now()) {
    res.status(401).set("Cache-Control", "no-store").json({ error: "invalid_token" });
    return;
  }
  req.grantorSub = entry.sub;
  req.grantorMode = entry.mode;
  next();
}

function buildMcpServer(sub, mode) {
  const server = new McpServer({ name: "grantor-mcp-example", version: "1.0.0" });
  server.registerTool(
    "whoami",
    {
      description:
        "Return the calling agent's verified, pseudonymous Grantor subject " +
        "and the deed mode it authenticated with (the `sub`/`mode` recomputed " +
        "by the verifier from the presented deed's own envelope).",
    },
    async () => ({ content: [{ type: "text", text: JSON.stringify({ sub, mode }) }] }),
  );
  return server;
}

app.post("/mcp", requireBearer, async (req, res) => {
  // Stateless per the SDK's own terminology (sessionIdGenerator: undefined):
  // a fresh McpServer/transport pair per request, closed over the sub this
  // bearer verified to. Good enough for a reference; a stateful deployment
  // would instead look sessions up by the transport's own session id.
  try {
    const server = buildMcpServer(req.grantorSub, req.grantorMode);
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    res.on("close", () => {
      transport.close();
      server.close();
    });
  } catch (e) {
    console.error("mcp request error:", e);
    if (!res.headersSent) {
      res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "internal error" }, id: null });
    }
  }
});
```

The example's one demo tool, `whoami`, returns `{ sub, mode }` — the
verified, pseudonymous `sub` the guard recomputed from the deed, and the
`mode` (`user-sig`/`agent-zk`) threaded through from the bearer set at
`/auth/token` — so the e2e can assert the round trip for both.

## The agent side — `authenticate()`

The reference agent is an MCP client that authenticates with a `user-sig`
deed instead of an OAuth redirect. Every crypto and verification step is
delegated to the shipped `authenticate()` — the caller supplies only a real
signing key and a pinned chain reader:

```js
import { authenticate } from "../../sdk/agent/ts/src/authenticate.js";
import { Registry } from "../../sdk/agent/ts/src/registry.js";

const chainReader = {
  call: async ({ to, data }) => {
    const { data: ret } = await publicClient.call({ to, data: toHex(data) });
    return ret ?? "0x";
  },
};

let deed;
try {
  deed = await authenticate(ORIGIN, signMessage, {
    chainReader,
    registry: Registry.devnet(CHAIN_REGISTRY),
    rpcUrl: RPC_URL,
  });
} catch (e) {
  // The hostile-origin leg lands here. Report the signer-call counter so the
  // e2e can assert it is STILL ZERO — the S2/Part-B property: a holder must
  // refuse an unvouched origin BEFORE ever asking its key to sign anything.
  console.log(`AUTH_FAILED: ${e && e.message ? e.message : e}`);
  console.log(`SIGN_CALLS: ${signCalls}`);
  process.exit(2);
}
```

`CHAIN_REGISTRY` is the holder-**pinned** registry address, wrapped in
`Registry.devnet(...)` (this example runs against an anvil devnet — a real
deployment uses `Registry.canonical()` or, for a licensed enterprise
registry, `Registry.dedicated(address, license)`). It is supplied by the
agent's own config, never read out of the server's discovery document.
`authenticate()` resolves it (via a live `eth_chainId` read over `RPC_URL`)
and uses it to confirm the server can show a tenant admin's on-chain vouch
for `ORIGIN` **before** it ever asks `signMessage` to sign anything — see
[Sovereign tier § Constructing a
`chainReader`](../sovereign-tier.md#constructing-a-chainreader). Against an
origin whose vouch does not cover it, `authenticate()` refuses with the
signing callback never invoked — the `signCalls` counter stays at zero.

Once minted, the deed is exchanged for the server's bearer and used to open
the MCP session:

```js
const tokenRes = await fetch(new URL("/auth/token", ORIGIN), {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ deed: JSON.stringify(deed), challenge: deed.challenge }),
});
const { access_token: accessToken, session_jwt: sessionJwt } = await tokenRes.json();

const transport = new StreamableHTTPClientTransport(new URL("/mcp", ORIGIN), {
  requestInit: { headers: { Authorization: `Bearer ${accessToken}` } },
});
const client = new Client({ name: "grantor-mcp-example-agent", version: "1.0.0" });
await client.connect(transport);
```

The agent opens the MCP session with `accessToken`, this server's own bearer.
`sessionJwt` (the RP-minted convenience artifact from the previous section) is
destructured too because the response carries it, but this client has no use
for its own server's session token — a real downstream service is what would
verify it.

## The fleet side — `agent-zk`

The `user-sig` agent above answers "is this tenant's bill paid?" — any wallet
may mint, because `user-sig` is permissionless by design. The `agent-zk`
agent answers a different question: "is this caller one of *my* enrolled agents?"
An `agent-zk` deed is a real Groth16 proof of anonymous membership in the
tenant's on-chain agent registry — only a commitment the tenant admin
registered with `GrantorRegistry.registerZkAgent` can produce a proof this
server accepts, and the proof does not reveal *which* member it is.

| | `user-sig` | `agent-zk` |
|---|---|---|
| Who can mint | any wallet holder — permissionless | only a commitment the tenant admin enrolled via `registerZkAgent` |
| Authorization question | "is this tenant's bill paid?" — authorization past that is the RP's own layer | "is this caller one of my enrolled agents?" — membership itself IS the authorization *for who can mint a valid `agent-zk` deed*; see "Both modes at once" below for whether an RP actually enforces that at `/auth/token` |
| Pseudonym | stable per `(tenant, origin)` wallet | stable per enrolled identity, anonymous **within** the fleet — the proof attests to membership, not identity |
| On-chain cost | zero per login | ~801k gas per `registerZkAgent` enrollment (one-time; `registerZkAgentBatch` amortizes across a batch) |

Choose `agent-zk` for a fleet you control and want gated by enrollment —
"only my deployed workers may call this tool server," with no separate
allow/deny list **for that mode**: the membership tree *is* the list for
`agent-zk` minting. Choose `user-sig` when any wallet-holding caller should
be let through and billing is the only gate you need. **Advertising `agent-zk`
alongside `user-sig` does not by itself make the server fleet-only**; see the
next section before calling a dual-mode deployment "gated."

### Both modes at once

`modes: ["user-sig", "agent-zk"]` in `grantorExpress`'s config controls what
`GET /.well-known/grantor-deed` *advertises* — nothing more. The guard
verifies whichever mode a deed's own envelope declares, and its `DeedClaims`
return value carries a `mode` field back out — crypto-checked, derived from
the verified branch, never trusted from the wire. But a field existing
enforces nothing: on a server with that `modes` list and nothing reading
`claims.mode`, `/auth/token` stays mode-agnostic — EITHER credential works,
and any wallet can mint a permissionless `user-sig` deed and get a bearer
indistinguishable from a fleet member's.

The full recipe is two-layered: an OPTIONAL pre-verify parse of the envelope
(`JSON.parse(deedJson).mode`) — cheap, no signature check or chain read — that
refuses a wrong-mode deed for free before paying for verification; and the
AUTHORITATIVE post-verify check against `claims.mode`, the value `verify`
derived from the branch it walked. `REQUIRE_MODE=agent-zk` implements the
pre-check and asserts the two agree (a mismatch would mean the guard verified
a different mode than the envelope declared — impossible by construction,
worth crashing on). The reference proves it: a `user-sig` deed refused with a
403 naming the reason; the registered fleet agent succeeds, reporting
`claims.mode == "agent-zk"`.

There is no `authenticate()`-style wrapper for this mode — `ZkAgent.mintDeed`
is the only `agent-zk` mint API in any language, so the caller composes
discover → pin-check → challenge → mint by hand, exactly as
[Sovereign tier § Minting an `agent-zk`
deed](../sovereign-tier.md#what-it-is) and its [RP integration
recipe](../sovereign-tier.md#rp-integration-recipe) prescribe:

```js
const d = await discover(ORIGIN);
if (!d) throw new Error(`${ORIGIN} does not publish a deed discovery document`);
if (!d.modes.includes("agent-zk")) {
  throw new Error(`${ORIGIN} does not advertise agent-zk (modes=${JSON.stringify(d.modes)})`);
}
// registryAddress is YOUR configuration, never `d.chain.registry`: a
// document-supplied eth_call target lets a hostile origin point the
// provenance check at a contract it controls, which just answers `true`.
if (d.chain.registry.toLowerCase() !== CHAIN_REGISTRY.toLowerCase()) {
  throw new Error(
    `discovery chain.registry ${d.chain.registry} does not match the pinned registry ${CHAIN_REGISTRY}`,
  );
}
const challengeRes = await fetch(new URL(d.challenge_endpoint, ORIGIN));
if (!challengeRes.ok) throw new Error(`challenge endpoint HTTP ${challengeRes.status}`);
const { challenge } = await challengeRes.json();

const now = Math.floor(Date.now() / 1000);
const exp = now + 60;
const v = d.origin_vouch;
// `mintDeed` runs origin provenance BEFORE proving (S2) — an unvouched
// origin fails with `BadOriginVouch` here, before any proof is generated.
// An unregistered commitment gets past provenance (this origin IS vouched)
// and fails at tree reconstruction instead, with `NotAMember` — before
// this script ever reaches the /auth/token exchange below.
deedJson = await agent.mintDeed(
  RPC_URL, Registry.devnet(CHAIN_REGISTRY), d.tenant, d.audience, ORIGIN,
  challenge, exp, v.signature, v.epoch, v.exp, ALLOW_INSECURE_ORIGIN, now,
);
```

`RPC_URL` and `CHAIN_REGISTRY` come from the agent's own environment, never
the discovery document — the same holder-pinned-registry rule `authenticate()`
enforces structurally for `user-sig`, done by hand here because `mintDeed`
never sees the document. `mintDeed`'s second argument is a `RegistryRef`
(`Registry.devnet(...)` in this anvil example;
`Registry.canonical()`/`Registry.dedicated(address, license)` for a real
deployment). `ALLOW_INSECURE_ORIGIN` (also environment-sourced, default-secure
`false`) gates the fail-closed origin policy `mintDeed` checks right after
canonicalising `ORIGIN`, before origin provenance or any chain call — a
non-HTTPS or non-routable origin is refused with `InsecureOrigin`, so local
dev against `http://localhost:...` must opt in explicitly. Past this point the
deed is exchanged for the server's bearer exactly like the `user-sig` path —
the guard dispatches on the deed's own `mode`.

To register a fleet agent, derive its identity and print the public
commitment with no network access, then enroll it as the tenant admin:

```sh
AGENT_PRIVATE_KEY=0x... node agent-zk.mjs --print-commitment
# -> registerZkAgent(tenantId, commitment) via GrantorRegistry, as a tenant admin
```

An unregistered commitment is refused with `NotAMember` — before any
request reaches `/auth/token`.

### Scale, honestly

**Verification does not get more expensive as the fleet grows.** The
Groth16 proof verifies against a fixed circuit at a pinned tree depth
(`depth_20`) — checking a proof costs the same whether the tenant has one
enrolled agent or a thousand — and the on-chain root-recency/tenant-status
reads the verifier makes on every login pass through `CachedGate`'s
short-TTL cache rather than hitting the chain each time.

**Minting is a different story.** A cold mint — nothing cached locally yet —
replays the tenant's *entire* on-chain registration event log to reconstruct
the Merkle tree; that cost grows with the number of registrations the tenant
has ever made, not the number currently live. `depth_20` gives every tenant a
ceiling of 2^20 (≈1,048,576) leaf slots, but today's tier caps (Free 2 / Pro
25 / Scale 250 agents) keep both numbers at a theoretical distance.
`registerZkAgent` is ~801k gas per agent (mostly the Merkle tree's Poseidon
hashing); `registerZkAgentBatch` amortizes that across a batch, and an L2 is a
precondition for the ZK tier at production fleet sizes, not an optimization.

## Every language ships the guard

This guide shows TypeScript because the example is a Node/Express app, but
the deed guard — challenge issuance, single-use burn, the shared error
codes — and both holder paths, `user-sig` and `agent-zk`, ship in
TypeScript, Python, Go and Rust; there is no Rust-only or TS-only capability
here. See [Every capability, every
language](../sovereign-tier.md#every-capability-every-language) and, for the agent
side specifically, [Agent tokens](agent-tokens.md) and [Wallet
login](wallet-login.md).

## Honest scope — read before pitching this at a real MCP host

This flow authenticates an MCP client whose code you or your
customer controls — an agent you built, or one your customer built against
your installed SDK. It does not make a remote MCP server work with Claude
Desktop or any other off-the-shelf host that expects to redirect a human
through a spec OAuth 2.1 authorization server; nothing here implements
that. Nor does the example attempt session scaling, persistence, or
multi-process deployment — the bearer store and challenge store are both
in-memory and single-process, exactly the caveat `MemoryChallengeStore`
carries in [Verify a deed](verify-tokens.md#scaling-out-a-shared-challenge-store).

## Run the proof

Every excerpt in this guide is transcribed from the reference example — this
runs it for real, live against anvil:

```sh
just verify-ts agent-ts   # build the wasm packages the example imports (once)
just mcp-e2e               # spin up anvil + two servers + both agents, all live
```

This deploys `GrantorRegistry`, funds a tenant, signs the tenant admin's
origin vouch, and starts two MCP servers — one genuine, one whose advertised
vouch does not cover it. It then drives both modes. The `user-sig` agent runs
twice against the genuine origin (a stable pseudonym across logins with the
same key) and once against the unvouched one (`authenticate()` refuses before
it ever asks the key to sign). A fleet key is then registered via
`registerZkAgent`, and the `agent-zk` agent runs twice with it (a stable
pseudonym across two real Groth16 proofs, distinct from the `user-sig` one)
and once with a never-registered key, refused with `NotAMember` before any
request reaches `/auth/token`.

## See also

- `examples/mcp-server/README.md` — the full reference example, including
  every environment variable and how to run it against your own chain.
- [Sovereign tier](../sovereign-tier.md) — discovery, origin binding,
  origin provenance, and what the verifier checks, in full.
- [Verify a deed](verify-tokens.md) — the deed guard, in depth.
- [Agent tokens](agent-tokens.md) and [Wallet login](wallet-login.md) — how
  `authenticate()` and its per-language equivalents work.
- [Errors](errors.md) — every error code a relying party branches on.
