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 — 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 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 below), and a live end-to-end proof against anvil (e2e.sh). Every excerpt below is transcribed from those files; run just mcp-e2e to see it work.
What you need#
- 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.
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#
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). 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 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);
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 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).
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";
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. 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 } = 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 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, 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:
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 and, for the agent side specifically, Agent tokens and Wallet login.
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.
Run the proof#
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 — 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.