docs / guide

view as .md

Permission broker (grantor-mcp)#

Every multi-agent framework eventually needs to answer: what does the sub-agent get to do? grantor-mcp (@grantor/mcp) is a small MCP server that answers it with a bounded, on-chain-anchored capability instead of a copy of the parent's own credentials. If you haven't already, read Concepts for the deed mental model and Capabilities (delegation) for the grant grammar this kit is built on — this page is the packaged, MCP-native version of that same mechanism.

The problem#

The easy way to give a sub-agent tools is to hand it what the parent already has: the same API key, the same signing key, the same "call anything" system prompt. That sub-agent can now do everything the parent can, for as long as that credential lives, and the only way to stop it is to rotate the parent's own key — which stops every OTHER sub-agent too. There is no per-child expiry, no per-child use budget, no way to narrow what one specific worker is allowed to do, and no cheap way to revoke just that one worker without a blast radius.

The model#

grantor-mcp sits between the parent and its sub-agents as a broker. The parent asks it to grant a sub-agent a capability — named tools, a use budget, an expiry — and the broker mints and holds a child identity for it, signed by the parent's own registered root key. From then on the parent (or the sub-agent itself, if it's MCP-aware) calls check before every tool use; the broker verifies the capability for real — not against its own bookkeeping alone, but against the public chain:

  • A registered root key. Every capability chain roots at a key registered on GrantorRegistry for a specific tenant. An unregistered or revoked root fails verification outright.
  • A live revocation epoch. Every link is signed against a revocation cohort at a specific epoch. The tenant admin can bump that epoch on-chain — one transaction invalidates every outstanding link signed against the old value, immediately, no per-link bookkeeping.
  • Tenant billing. The tenant behind the capability must be Active or Grace on-chain, or nothing verifies — the same billing gate every other deed mode enforces.

A capability can only ever get narrower as it's handed onward — fewer tools, a shorter expiry, a smaller use budget. The broker enforces this at signing time, but not identically for every field: asking delegate for a tool the parent doesn't hold is refused outright, before any key is touched. Asking for more uses or a longer expiry than the parent holds is not refused at all — it's silently clamped down to the parent's own bound instead, so a delegate call can succeed while granting fewer uses or less time than requested. Either way, narrowing is enforced a second time, independently, at verification: a forged or bypassed-broker deed asserting more than its chain actually delegated is refused by the cryptographic chain walk itself, not by anything the broker remembered.

Walkthrough#

The transcript below is the shape of sdk/mcp/e2e.sh's live run (grant → delegate → check ALLOW → escalation DENIED → revoke), shown as MCP tool calls. The same five calls work identically from the CLI (node src/cli.js <verb> ...) or over MCP stdio from any client.

1. Grant a sub-agent a bounded capability — search and fetch, 5 uses, one hour:

grant({tools: ["search", "fetch"], max_uses: 5, ttl_secs: 3600})
  -> {"child_id": "a1b2c3d4-...", "sub": "9f3c...", "grants": [...], "exp": 1786020000}

The broker signs a Delegation under the hood, rooted at the operator's own registered agent key, and holds the resulting child identity — child_id is what the parent hands the sub-agent, never a key.

2. Delegate a narrower slice onward — the sub-agent itself spawns a worker, and gets search only, 3 uses:

delegate({parent: "a1b2c3d4-...", tools: ["search"], max_uses: 3})
  -> {"child_id": "e5f6a7b8-...", "sub": "7e21...", "grants": [...]}

Every field named here is bounded to be no wider than the parent's own grant, but not the same way: passing tools: ["search", "write"] at this step would be refused before any key is touched, naming exactly which requested grant doesn't narrow any parent grant — write isn't in the parent's tool set, and there's no bound to clamp it to. Passing max_uses: 999 instead would NOT be refused; it would silently come back clamped to whatever the parent's own remaining bound actually is.

3. Check — ALLOW:

check({child_id: "e5f6a7b8-...", tool: "search"})
  -> {"allow": true, "remaining_uses": 2}

A real on-chain verification ran here: the chain of custody back to the registered root key, the live revocation epoch, and the tenant's billing status, plus the offline narrowing check and the local use-budget decrement. remaining_uses just dropped from 3 to 2.

4. Check — escalation DENIED:

check({child_id: "e5f6a7b8-...", tool: "write"})
  -> {"allow": false, "code": "CapabilityDenied",
      "reason": "grant does not cover mcp://grantor-mcp/tools/write"}

The worker's capability is genuine — the check above just succeeded — it simply never covered write. This is the deliberately distinct case from every other refusal: the deed checked out, the caller is authenticated, it just isn't authorized for this specific action.

5. Revoke:

revoke({child_id: "a1b2c3d4-...", epoch_label: "..."})
  -> {"revoked": "onchain", "tx": "0x...", "epoch_label": "..."}

On your own tenant this is a real GrantorRegistry.bumpEpoch — and because the worker's delegation (step 2) was signed against the SAME revocation cohort as its parent, this one transaction invalidates both the parent's capability and everything delegated from it, immediately:

check({child_id: "e5f6a7b8-...", tool: "search"})
  -> {"allow": false, "code": "EpochRevoked",
      "reason": "link signed at epoch 0, tenant is now at epoch 1"}

No per-child cleanup, no separate revocation call for the worker — one bumpEpoch on the shared cohort took out the whole sub-tree it rooted.

Sandbox vs. your own tenant#

The package ships with a working sandbox out of the box (npx @grantor/mcp serve, no config) so you can run the walkthrough above immediately. It trades real limits and revocation for zero setup:

Shared sandbox (default)Your own tenant
SetupNone — bundled configGRANTOR_MCP_CONFIG pointing at your registered tenant
Principal keyPublished, shared by everyone who runs the packageYours; only you hold it
Controls funds / admin powerNo — registered for delegation only, no billing powerWhatever your tenant's admin key controls
Revocation (revoke)Local, at the broker (the sandbox key's only check-point)Real on-chain bumpEpoch — invalidates every outstanding link, everywhere, instantly
Billing / limitsShared, operator-fundedYour own — your tenant, your tier, your caps
Use-budget meteringBroker-local (this process's state file)Same mechanism, but now it's YOUR broker instance and YOUR state
Good forTrying the arc, local development, demosAnything a sub-agent's authority should actually matter for

Full field-by-field config shape, env vars, and the operator side of standing up a shared broker deployment: docs/deploy/mcp-sandbox.md (../deploy/mcp-sandbox.md, not published). The sdk/mcp/README.md sandbox-honesty block spells out exactly what the published sandbox key can and can't do, in more detail than the table above.

Getting your own tenant#

Becoming a tenant is a handful of on-chain transactions against GrantorRegistry — no signup form, no account. The register page walks any wallet through it (USDC on Base, funds land in your own tenant balance, nothing else touches a Grantor server because there is no Grantor server). Point grantor-mcp at the result with GRANTOR_MCP_CONFIG and you have real limits, real revocation, and a billing relationship that's entirely yours.

See also#

  • sdk/mcp/README.md — the package README: the 60-second first run, the full tool reference, and the DENY-code table.
  • sdk/mcp/SKILL.md — the agent-facing quick reference: tool-call sequences and what to do on each DENY code.
  • Capabilities (delegation) — the underlying grant grammar, narrowing rules, and revocation mechanics this kit packages.
  • Errors — the full verifier-wide error/code table.