Capabilities (delegation)#
A capability deed carries a bounded grant on top of the identity every deed already proves — not just "who is calling" but "what this specific caller is allowed to do, and until when." One principal delegates a narrowed slice of its own authority to another identity by signing a Delegation; the receiving identity attaches that link to its own deed and presents it. Your app verifies the whole chain in one call and gets back the caller's identity plus the grants that actually apply — then enforces each request against them with CapabilityGuard. If you haven't already, read Concepts for the deed mental model this builds on.
Every excerpt below is transcribed from the runnable reference example. The zk-chain mode is ★ preview — see "Structure-hiding delegation" below.
The grant grammar#
{
"res": "mcp://acme/tools/*",
"act": ["call"],
"cav": { "exp": 1786015180, "max_uses": 100, "nb": { "tool": "search|fetch" } }
}
A Grant names a resource pattern (res), the actions allowed on it (act), and caveats (cav) bounding it:
resis an exact string, or a prefix ending in one trailing*(mcp://acme/tools/*matchesmcp://acme/tools/search, notmcp://acme/other).actis the set of actions this grant covers on that resource ("call","write", whatever your app's own vocabulary is).cav.expbounds when the grant stops being usable;cav.max_usesis carried on every grant your guard inspects but is not enforced by the guard itself — counting real uses needs durable state (a DB row, a Redis counter) that only your app can own;cav.nb(narrow-by) is a set of named predicates —{"tool": "search|fetch"}means the request'stoolargument must be exactlysearchorfetch.
A delegated grant can only ever get narrower, never wider: a child link in a chain must name a resource pattern, an action set, and caveats that are each a subset of its parent's. A grant with no caveat at all is the widest possible caveat, so it may only appear at the root of a chain.
Delegating — delegate#
A principal signs a Delegation handing another identity a narrowed grant, valid until exp, tagged with the revocation cohort it was signed against (epoch_label/epoch — see Revocation below):
import { delegationBinding, parentRefHashRoot, delegate } from "@grantor/sdk";
const grant = {
res: "mcp://acme/tools/*", act: ["call"],
cav: { exp, max_uses: 100, nb: { tool: "search|fetch" } },
};
const grantsJson = JSON.stringify([grant]);
const parentRefHash = parentRefHashRoot(iss); // the first link in a chain
const bindingHex = delegationBinding(tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash);
const signatureHex = await sign(bindingHex); // your own secp160k1/EIP-191 signer
const link = delegate(
tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash,
undefined, // pubkey — omitted for a recoverable EIP-191/secp256k1 signer
signatureHex,
);
A delegation chain's root must be a registered agent key — GrantorRegistry.registerAgentKey(tenantId, commitment), where commitment is keccak256("0x{lowercase 20-byte address}"), the delegator's own address string. This is the same on-chain mapping admin-sig deeds check, under a different preimage than agent-zk's CAIP-10 commitment — a key registered for one does not root a chain for the other. A chain rooted at an unregistered or revoked key fails verification (BadDelegation).
A chain can have more than one link — an agent that received a grant can delegate a narrower slice of it onward, the same way — but every link after the root must narrow the immediately preceding link's grants, and the last link's to must equal the presenting identity's own proven sub.
Anonymous delegation — delegateZk#
import { ZkAgent } from "@grantor/agent";
import { parentRefHashRoot } from "@grantor/sdk";
const agent = ZkAgent.fromAgentKeySignature(agentKeySignatureBytes);
const grantsJson = JSON.stringify([grant]);
// A delegation's `iss` is a Semaphore nullifier fixed by (identity, tenant)
// alone — computed before the proof, but only ever returned FROM one. Mint
// once (any parent hash — even a placeholder `zeroHash` — works to learn
// `iss`; that proof is discarded), then mint again with the correct parent
// hash now that `iss` is known.
const probe = JSON.parse(await agent.delegateZk(
rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash,
));
const parentRefHash = parentRefHashRoot(probe.iss);
const link = JSON.parse(await agent.delegateZk(
rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash,
));
A member of tenant T can grant another identity a bounded slice of authority without revealing which member it is. Instead of a recoverable EIP-191 signature, delegateZk authenticates the link with a Semaphore membership proof over the same on-chain ZK tree ZkAgent.mintDeed proves against — the proof is the authentication, so a zk-kind Delegation carries no sig/pubkey. Its iss is a delegation nullifier: a per-tenant pseudonym, deterministic for a given identity but unlinkable to the registered commitment.
Your app verifies a zk-rooted chain through the same entry point as a signed one — verifyWithCapabilityAt/verify_deed_with_capability. The claims name the delegate's proven identity as usual; the only thing it learns about the anonymous delegator is that nullifier, printed here as its pseudonym:
delegator pseudonym (nullifier) iss=4629071830519134272348858930177220791871450050742923343585703717248052541528
Only the chain's root is checked against the registry — the same "root-only" gating a signed chain uses. An intermediate zk link's root is authenticated by its own nullifier and proof, not separately checked on-chain; recency is enforced once, where the chain's authority actually originates.
Three independent bounds, all live against the real registry. The first two — exp and bumpEpoch — work exactly as in Revocation below. The third is zk-specific:
- Revoking the member (
GrantorRegistry.revokeZkAgent) invalidates every already-signed delegation rooted at it, immediately — it overwrites the on-chain root history for the WHOLE tenant tree, not just that member, so the next root-recency check fails closed with aBadDelegationworded as a stale root ("not a currently-recent registry member"), independent of the epoch state that link was signed against.
Unlinkable across chains — delegateZk(..., salt)#
const grantsJson = JSON.stringify([grant]);
// Same two-call pattern as above, with one addition: a per-chain `salt` as
// the trailing argument to `delegateZk`.
const probe = JSON.parse(await agent.delegateZk(
rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash, salt,
));
const parentRefHash = parentRefHashRoot(probe.iss);
const link = JSON.parse(await agent.delegateZk(
rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash, salt,
));
salt is an optional trailing string on delegateZk, in every language. It scopes the delegation nullifier further, so the SAME anonymous member delegating twice with two different salts produces two pseudonyms that share nothing — no field on either deed, or anywhere on the wire, ties them back to one delegator:
chain A (salt="chain-a"): delegator pseudonym iss=826493158991174290343971509743582095105037072152399465317093330796638885617
chain B (salt="chain-b"): delegator pseudonym iss=14936235482157490080433353871006983321048803913765619935150511037695437371846
Omitting salt (or passing undefined/null) reproduces the one stable pseudonym this member already uses for the tenant. It is a per-chain choice: mint a fresh salt for a chain you want unlinkable from the member's others, or leave salt off where staying recognizable is the point.
Verification does not change: verifyWithCapabilityAt recomputes the nullifier scope from whatever salt the deed carries, so salted and unsalted chains verify through the same call. A salt tampered after minting rejects with BadDelegation — the proof was built over the original value, so recomputing against a different one lands on the wrong nullifier scope.
Presenting — mintCapabilityDeed#
The identity receiving a delegation attaches it to its own already-minted deed (a user-sig login, an agent-zk proof, whatever mode it authenticates with) and presents the result instead of the bare identity deed:
import { mintCapabilityDeed } from "@grantor/sdk";
const baseDeed = await authenticate(origin, signMessage, { chainReader, registry, rpcUrl });
const capabilityDeed = mintCapabilityDeed(baseDeed, grantsJson, JSON.stringify([link]));
grantsJson here is the presenter's own asserted grants — normally the same grant it received, sometimes a narrower slice of it. mintCapabilityDeed does not itself check that the assertion is a legal narrowing; that check is what verification does next, so a forged or widened assertion is caught at the one place that matters, not trusted at mint time.
Verifying — verifyWithCapabilityAt#
Your app verifies a capability deed with one call, alongside the ordinary identity check — it walks the chain root to leaf, recovers each link's signer cryptographically (never trusts the claimed iss), checks every narrowing step, and confirms the root is still an enrolled, non-revoked agent key:
const { claims, grants } = await verifier.verifyWithCapabilityAt(deedJson, challenge, now);
claims is the same identity verify_deed already gives you; grants is the chain's effective authority — [] for an identity-only deed with no delegation attached, so this call is a strict superset of the plain identity check and safe to use everywhere, not just on capability-gated routes.
Enforcing — CapabilityGuard#
grantorExpress ships a matching pair of middleware: protectCapability verifies the presented deed and attaches req.deed/req.grants; requireCapability checks one route's own resource/action against those grants, denying with CapabilityGuard.authorize's rules if nothing covers the request:
app.post(
"/mcp/tools/:tool",
g.protectCapability,
g.requireCapability("mcp://acme/tools/*", "call", {
argsFrom: (req) => ({ tool: req.params.tool }),
}),
(req, res) => res.json({ tool: req.params.tool, sub: req.deed.sub }),
);
A request to /mcp/tools/search or /mcp/tools/fetch passes — the grant's nb: {"tool": "search|fetch"} predicate matches. A request to /mcp/tools/write is refused: the deed itself is genuine (verification already succeeded), it just carries no grant that covers tool=write — CapabilityDenied, HTTP 403, deliberately distinct from every other code in this table, which means "this deed did not check out" and maps to 401/503 instead. See Errors for the full table.
Calling CapabilityGuard.authorize directly (outside Express, or from a non-HTTP call site) looks the same, against grants you already verified:
import { CapabilityGuard } from "@grantor/verify";
CapabilityGuard.authorize(grants, {
res: "mcp://acme/tools/search", act: "call", args: { tool: "search" }, now,
});
Revocation#
Three independent mechanisms bound how much a delegation can do and for how long, at three different scopes:
- Caveats bound the blast radius of one grant.
res/act/nbnarrow exactly what a delegated grant covers — a grant scoped to{tool: "search|fetch"}never covers awritecall no matter how it is presented. - Each link's own
expbounds its window. A delegation link — and the leaf deed it is attached to — simply stops verifying once its ownexppasses, no on-chain action needed. bumpEpochis the instant, bulk kill. EveryDelegationis signed against a revocation cohort (epoch_label, defaulting to the tenant-wide cohort) at a specificepoch. The tenant admin callsGrantorRegistry.bumpEpoch(tenantId, label)to increment that counter — one transaction invalidates every outstanding link signed against the old value for that cohort, immediately, with no per-link bookkeeping:
RESULT: {"search":{"status":401,"body":{"error":"EpochRevoked", …}}, …}
Your verifier's own chain-read cache (if you run one) bounds how quickly a bumpEpoch is observed — a cached delegation_epoch read serves the old value until its TTL expires, the same tradeoff the tenant-active and root-recency caches already make. The revocation itself is instant on-chain; your app's cache TTL is the only thing standing between the transaction and your next verification seeing it.
Structure-hiding delegation (preview)#
⚠️ PREVIEW. This mode uses a single-contributor, deterministic trusted setup — not a multi-party ceremony. The toxic waste is not assumed discarded, and the circuit is pending external cryptographic review. Treat every
zk-chainproof as unauthenticated for anything beyond a demo, and do not use this mode for production authority. Every other mode on this page (delegate,delegateZk) is unaffected.
Every mode above still reveals something about the chain's shape: a Delegation array on the wire, one link per hop, each carrying a real signature or proof. Structure-hiding delegation collapses an entire chain — however many hops, however it narrows — into ONE Groth16 proof and one opaque effective-grant commitment (g_eff). The deed that reaches your server carries no delegations array and no way to recover a hop count from the wire at all.
// Holder side (a script that plays every role in the chain — see
// examples/mcp-server/capability-chain.mjs): authorize N hops, fetch the
// root's real on-chain membership witness, and prove.
import { ChainHopAuthorizer, proveDelegationChain } from "@grantor/agent";
const auth = new ChainHopAuthorizer(tenantId, epoch);
const hop0 = auth.authorizeHop(member, agentA.publicHex(), grantJson);
const hop1 = auth.authorizeHop(agentA, agentB.publicHex(), grantJson);
const hop2 = auth.authorizeHop(agentB, worker.publicHex(), grantJson);
const deedJson = proveDelegationChain(
`[${hop0},${hop1},${hop2}]`, siblingsHex, isLeft, worker,
rootHex, challenge, tenantId, audience, deedExp, epoch,
);
// RP side: verify the proof for real, then OPEN and enforce g_eff.
const claims = await verifier.verifyDelegationChainAt(deedJson, challenge, now);
const gEff = arithGrantCommit(JSON.stringify(claimedGrantValues));
if (gEff !== claims.sub) throw new Error("does not open the proven commitment");
CapabilityGuard.authorize([openedGrant], { res, act, args, now });
verifyDelegationChainAt is a dedicated entry point — it never touches verify_deed/verify_deed_with_capability, and a non-zk-chain deed is refused there either way, the same isolation admin-sig gets for a different reason. It does real Groth16 verification against a committed verifying key, plus the exact same tenant/root/epoch chain gates every other mode uses — no new contract, no new gate method.
Opening g_eff is your job, not the verifier's#
The in-circuit narrowing checks each hop's caveat-predicate commitment (nb_commit) by equality, never by opening it — so a Groth16-verified zk-chain deed has proven what authority exists (an opaque commitment), not that this specific request is covered by it. verifyDelegationChainAt resolves sub to that commitment and stops there on purpose. Your app must:
- build the
GrantValues({res_id, act_mask, exp, max_uses, nb_commit}) it believes the chain committed to — from its own interning tables, never from anything an untrusted caller asserts (see the next section); - recompute
arithGrantCommitover those values and require it equalclaims.sub— only a value that opens the real commitment is ever enforced; - run the opened grant through
CapabilityGuard.authorizeexactly like a P0/P1 effective grant — sameres/act/nbrules, same 403CapabilityDeniedon a miss.
CapabilityGuard::authorize_zk_chain is the Rust-native version of this same two-step split; arithGrantCommit (@grantor/verify) is the wasm export that lets TypeScript perform the same Poseidon-hash check without reimplementing it.
The res-interning contract is yours to keep#
A circuit sees five field elements, not a resource glob or a JSON caveat map. Your app assigns the meaning — which res_id is which resource pattern, which bit of act_mask is which action, which nb_commit is which predicate set — the same way the reference example's zk-chain route keeps a small fixed table (res_id: 7 ⇒ "mcp://acme/tools/*", bit 0 ⇒ "call", one nb_commit id ⇒ {tool: "search|fetch"}) and refuses anything outside it. This module cannot validate that mapping for you — a mis-interning is an integration bug in your app, not a circuit bug.
The WireGrant precision ceiling#
exp/max_uses cross the wire as plain JSON numbers, not hex — so they must stay under 2^53 - 1 (Number.MAX_SAFE_INTEGER). Both are naturally small (a unix timestamp, a usage cap), so this is not a practical limit, but WireGrant's parser refuses a larger value outright rather than proving over one a JS caller's own runtime already silently corrupted.
Cost: real proving, every time#
Structure hiding costs a real Groth16 proof, not a signature check — ~18-22 seconds measured in Node (wasm), ~0.8 seconds native. This is server-side/agent-side work, not something you'd ask a browser tab to do on every click; the reference exchanges one proof for a short-lived session bearer (POST /auth/token/zk-chain) rather than re-proving per tool call, the same "deed is the login, bearer is the session" shape every other mode uses.
Run the proof#
just verify-ts agent-ts sdk-ts # build the wasm packages the example imports (once)
just caps-e2e # delegate -> present -> enforce -> revoke, all live
This deploys GrantorRegistry, funds a tenant, and starts the reference server, then drives four legs live against a chain:
- Signed delegation. A principal registers as an on-chain agent key and delegates "call tools search|fetch, up to 100 uses, ~1h"; the worker presents the capability deed —
search/fetchsucceed,writeis refused withCapabilityDenied.bumpEpochon the cohort then makes the same deed fail withEpochRevoked. - Anonymous delegation. The same grant via
delegateZk; the transcript prints the delegator's pseudonym, never its commitment. Both zk revocation channels are proven on one deed:bumpEpoch(EpochRevoked) andrevokeZkAgent(BadDelegation, worded as a stale root). - Salted chains. The same member delegates on two salted chains plus one unsalted: the salted deeds print two different pseudonyms; the no-salt deed reproduces the member's one stable pseudonym.
- Structure-hiding (★ preview). A 3-hop chain (member → agent A → agent B → worker) collapses into ONE Groth16 proof; the server verifies it, opens
g_effagainst its interning table, and enforces the samesearch/fetch-allowed,write-denied outcome. Proving is real (~18-22 s), so this is the slowest leg. Two revocation legs each prove a FRESH deed (azk-chaindeed's challenge is bound into its proof):bumpEpoch(EpochRevoked) andrevokeZkAgent(StaleRoot).
See also#
examples/mcp-server/README.md— the full MCP reference, including the identity modes capability deeds build on top of.- Errors —
BadDelegation,EpochRevoked,CapabilityDenied. - Verify a deed — the plain identity check this extends.
- Concepts — the deed mental model.