# 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).

The runnable reference this guide describes lives at `examples/mcp-server/`, starting with `server.mjs`'s wiring below.

### 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 relative paths into this workspace, not an npm
package — nothing is published yet. `sdk/verify/ts` is the source of truth;
swap in your package name once it is.)

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 (`sdk/verify/ts`); 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.** The verifier construction above and `agent-zk.mjs`'s `mintDeed`
   call both take it, default-secure — set it `true` only while `ORIGIN` is a
   `localhost`/dev origin; a real deployment must run with it unset so a
   non-HTTPS or non-routable `ORIGIN` fails loudly instead of quietly serving
   insecure logins. See [Sovereign tier § Fail-closed origin
   policy](../sovereign-tier.md#fail-closed-origin-policy).

## The three endpoints

`examples/mcp-server/server.mjs` wires exactly three routes around the
shipped guard, plus the MCP transport itself. The first — discovery and the
challenge endpoint — is shown above, right after the intro; the other two
follow below. (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`, `sdk/verify/ts/src/express.js` —
the same helper `g.protect`/`g.protectCapability` use internally, called by hand here because this
route calls `g.guard.verify` directly instead of the header-based middleware). A 401 (not
authenticated — 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 own discovery document and the global onboarding
manifest; a 503 (`Chain`/`QuorumDivergence`/`WrongChain`/`LicenseExpired` — an RP/network
problem, not the caller's credential) gets neither, since there is nothing to fix by
re-authenticating. This is opt-out, not mandatory: pass `funnelHints: false` to
`writeRejection`/`grantorExpress` to keep the pre-funnel `{error, error_description}` shape only.
See [the funnel design](../superpowers/specs/2026-08-10-agent-native-gtm-design.md) for the full
contract, including why `ModeNotAllowed` above stays hand-rolled (it is this route's own policy,
not a rejection the shipped guard raised, so there is no shared error class to fold it into).

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

The response 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, alongside `access_token`
rather than instead of it: this example still gates `/mcp` with the bearer
map, so both the "roll your own session" and the "one-call convenience"
paths are demonstrated side by side, not one silently replacing the other.

### 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()`

`examples/mcp-server/agent.mjs` 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)`) — supplied by the agent's
own config, never read out of the discovery document the server publishes.
`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). This is
the exact property `e2e.sh`'s third leg proves: against an origin whose
vouch does not cover it, `authenticate()` refuses with the signing callback
never invoked — `agent.mjs`'s `signCalls` counter (incremented only inside
`signMessage`, not shown above) is how that leg asserts it, staying at zero
through the refusal.

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);
```

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

## The fleet side — `agent-zk`

`agent.mjs` above answers "is this tenant's bill paid?" — any wallet may
mint, because `user-sig` is permissionless by design. `examples/mcp-server/agent-zk.mjs`
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 anywhere in `server.mjs` **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` — the default in this
example — 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 shipped
guard verifies whichever mode a deed's own envelope declares. Its
`DeedClaims` return value now DOES carry a `mode` field back out —
crypto-checked, derived from the verified branch, never trusted from the
wire — but a field existing enforces nothing by itself: on a server
configured only with that `modes` list and nothing reading `claims.mode`,
`/auth/token` stays mode-agnostic in effect, EITHER credential works, and
any wallet can still mint a permissionless `user-sig` deed and get a bearer
indistinguishable from a fleet member's. `examples/mcp-server/README.md`'s
"Both modes at once" section has the full recipe, now two-layered: an
OPTIONAL pre-verify parse of the deed's own envelope
(`JSON.parse(deedJson).mode`), cheap — no signature check, no chain read —
so a wrong-mode deed can be refused for free before paying for verification;
and the AUTHORITATIVE post-verify check against `claims.mode`, the value
`verify` itself derived from the branch it walked. `server.mjs`'s
`REQUIRE_MODE=agent-zk` implements the cheap 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); it is
the reference implementation, proven by `e2e.sh`'s `REQUIRE_MODE` leg (a
`user-sig` deed refused with a 403 naming the reason; the registered fleet
agent still succeeds, with the exchange itself 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 `agent-zk.mjs`'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 at all. `mintDeed`'s second
argument is a `RegistryRef` (`Registry.devnet(...)` in this anvil example;
`Registry.canonical()`/`Registry.dedicated(address, license)` for a real
deployment), resolved internally the same way `authenticate()` resolves its
own `registry` option. `ALLOW_INSECURE_ORIGIN`
(also environment-sourced, default-secure `false`) gates the fail-closed
origin policy `mintDeed` checks right after canonicalising `ORIGIN` and
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`, and nothing else in `server.mjs`
branches on which one showed up.

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 (`grantor_sdk_core::treesync::reconstruct_tree`,
the tree-sync path); that cost grows with
the number of registrations the tenant has ever made, not the number
currently live, until the queued tree-sync/checkpointing tooling ships.
`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) put both
of those numbers at a purely theoretical distance. `registerZkAgent` itself
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#the-default-in-memory-challenge-store).

## Run the proof

Every excerpt in this guide is transcribed from `examples/mcp-server/`'s own
files — this runs them 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
```

`just mcp-e2e` runs `examples/mcp-server/e2e.sh`, which deploys
`GrantorRegistry`, creates and 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:
`agent.mjs` (`user-sig`) twice against the genuine origin (the pseudonym is
stable across logins with the same key) and once against the unvouched one
(`authenticate()` refuses before it ever asks the agent's key to sign
anything); then a fleet key is registered on-chain via `registerZkAgent` and
`agent-zk.mjs` (`agent-zk`) runs twice with that key (a stable pseudonym
across two real Groth16 proofs, and distinct from the `user-sig` pseudonym
above) and once with a different, never-registered key, which is 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.
