docs / guide

view as .md

MCP server auth (deed-gated)#

MCP authorization is optional, but when a remote Model Context Protocol 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 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#

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). A misconfigured vouch fails the grantorExpress(...) call itself, not the first agent's login.

Before you run it#

  1. A tenant on the registrycreateTenant plus funding on GrantorRegistry. No signup, no account. See Getting started.
  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.
  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.
  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.

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

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 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 below for why that check has to live here, not in the guard:

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

It also carries session_jwt — the RP's own standard ES256 JWT, minted with the one-call convenience Verify a deed § 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:

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:

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

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-sigagent-zk
Who can mintany wallet holder — permissionlessonly 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
Pseudonymstable per (tenant, origin) walletstable per enrolled identity, anonymous within the fleet — the proof attests to membership, not identity
On-chain costzero 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 and its RP integration recipe prescribe:

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:

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 and, for the agent side specifically, Agent tokens and Wallet login.

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.

Run the proof#

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

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 — discovery, origin binding, origin provenance, and what the verifier checks, in full.
  • Verify a deed — the deed guard, in depth.
  • Agent tokens and Wallet login — how authenticate() and its per-language equivalents work.
  • Errors — every error code a relying party branches on.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.