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).
The runnable reference this guide describes lives at examples/mcp-server/, starting with server.mjs's wiring below.
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 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). A misconfigured vouch fails the grantorExpress(...) call itself, not the first agent's login.
Before you run it#
- A tenant on the registry —
createTenantplus funding onGrantorRegistry. No signup, no account. See Getting started. - A signed origin vouch for wherever your MCP server runs. It is required in the discovery document —
parse_discoveryrejects one without it — and it is what lets an agent'sauthenticate()call refuse a hostile origin before signing anything. See Sovereign tier § Origin provenance. - 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. ALLOW_INSECURE_ORIGINleft unset (orfalse) for anything but localhost. The verifier construction above andagent-zk.mjs'smintDeedcall both take it, default-secure — set ittrueonly whileORIGINis alocalhost/dev origin; a real deployment must run with it unset so a non-HTTPS or non-routableORIGINfails loudly instead of quietly serving insecure logins. See Sovereign tier § 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.)
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, 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, not published) 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 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).
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 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:
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:
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. 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:
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 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 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:
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 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 examples/mcp-server/'s own files — this runs them 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
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 — 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.