# 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 — and
that requirement is the ecosystem's loudest operational pain point. 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/` — a real Express app (`server.mjs`), two real MCP
clients that authenticate with a deed instead of a browser redirect
(`agent.mjs` for `user-sig`, `agent-zk.mjs` for a gated fleet — see [The
fleet side — `agent-zk`](#the-fleet-side-agent-zk) below), and a live
end-to-end proof against anvil (`e2e.sh`). Every excerpt below is
transcribed from those files; run [`just mcp-e2e`](#run-the-proof) to see
it work.

## What you need

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](../index.md#every-capability-every-language).

## The three endpoints

`examples/mcp-server/server.mjs` wires exactly three routes around the
shipped guard, plus the MCP transport itself.

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

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

const verifier = new DeedVerifier(
  RPC_URL,
  REGISTRY,
  TENANT_ID,
  AUDIENCE,
  ORIGIN,
  MAX_TTL_SECS,
  CACHE_TTL_SECS,
);

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.

### 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);
    const { token, exp } = mintBearer(claims.sub, mode);
    res
      .set("Cache-Control", "no-store")
      .json({
        access_token: token, token_type: "bearer",
        expires_at: Math.floor(exp / 1000), sub: claims.sub, mode,
      });
  } catch (e) {
    if (e instanceof DeedRejected) {
      res.status(e.status).set("Cache-Control", "no-store").json({ error: e.code, error_description: e.message });
      return;
    }
    console.error("auth/token error:", e);
    res.status(500).json({ error: "InternalError" });
  }
});
```

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

### 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";

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, chainRegistry: CHAIN_REGISTRY });
} 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 — supplied by
the agent's own config, never read out of the discovery document the
server publishes. `authenticate()` 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 } = 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 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, and its
`DeedClaims` return value carries no `mode` field back out, so `/auth/token`
itself is mode-agnostic by construction: on a server configured only with
that `modes` list, 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: check the deed's own envelope `mode` at the
exchange (`JSON.parse(deedJson).mode`) — the only sound place to fleet-gate,
since it's the one field that's both attacker-supplied and independently
crypto-checked by `verify` regardless of what this check decides — and
`server.mjs`'s `REQUIRE_MODE=agent-zk` 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).

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, CHAIN_REGISTRY, d.tenant, d.audience, ORIGIN, challenge, exp,
  v.signature, v.epoch, v.exp, 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. 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 the tree depth SP7 pins
(`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`,
what that module's own doc comment calls "SP7 tree-sync"); 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; see
`docs/strategy/2026-07-25-unit-economics-and-chain-selection.md`) 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 treated
as a precondition for the ZK tier at production fleet sizes, not an
optimization (R1/R4 in the unit-economics doc above).

## 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](../index.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

From the decision record (`docs/strategy/2026-07-31-verticals-one-registry.md`):

> **★ Honest scope, do not overclaim:** off-the-shelf MCP hosts (Claude
> Desktop et al.) speak spec-standard OAuth 2.1 (RFC 9728 protected-resource
> metadata → AS flow). The deed flow serves **agents you build or
> control** — exactly the agent-economy thesis (the customer owns the agent
> code, so the holder SDK is installable). Spec-compatible OAuth bridging
> for third-party MCP hosts would require an authorization server — the
> hidden issuer could someday play that role, but that is a separate,
> deliberate decision, not a fold-in.

Concretely: 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

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