@grantor/mcp
v0.2.0
Published
Grantor permission broker for multi-agent frameworks — grant, delegate, check, revoke bounded capabilities over MCP, and wrap any stdio MCP server with enforced permissions. No authorization server anywhere.
Maintainers
Readme
@grantor/mcp
A permission broker for multi-agent frameworks. When a parent agent
spins up a sub-agent, the easy thing to do is hand it the same credentials
the parent has — every tool, no expiry, no way to take it back short of
rotating the parent's own key. grantor-mcp is the other option: an MCP
server that sits between the parent and its sub-agents and hands out
bounded capabilities — named tools only, a use budget, an expiry —
anchored on-chain (a registered root key, a live revocation epoch, a billed
tenant), not just an in-memory promise. Widening is bounded two ways: asking
for a tool the parent doesn't hold is refused at signing time; asking
for more uses or a longer expiry than the parent holds is silently
clamped down to the parent's own bound. Either way, the chain walk
re-enforces narrowing cryptographically at verify, independent of what
the broker did at signing. No authorization server anywhere — this is a
library, not a service.
And it isn't only advisory: grantor-mcp wrap runs any stdio MCP server
behind the broker as an enforcing proxy — ungranted tools are denied
before the server ever sees the request, granted tools are metered, and an
on-chain revocation lands on the wrapped session's very next call. See
Wrap any MCP server (enforcing).
Ships with a published sandbox principal so you can try the whole arc — grant, delegate, check, escalate, revoke — before you own a tenant. See Sandbox vs. your own tenant for exactly what that key can and can't do.
30 seconds: watch a denial happen
npx -y @grantor/mcp demoWraps a bundled toy server behind a grant for one tool, lets the granted
call through, and shows delete_everything getting refused before the
server sees it — enforcement is real (broker + on-chain revocation read),
only the "agent" is scripted. Then inspect your own server and get a
ready-to-paste wrap suggestion:
npx -y @grantor/mcp tools -- <your MCP server command>The suggestion grants read-like tool names only (a name heuristic — review
it before trusting it); everything outside --tools is denied.
60-second first run
Add it to Claude Code:
claude mcp add grantor-mcp -- npx -y @grantor/mcp serveOr wire it into any MCP client's config directly:
{
"mcpServers": {
"grantor-mcp": {
"command": "npx",
"args": ["-y", "@grantor/mcp", "serve"]
}
}
}No API key, no signup, no config file required — the first run uses the
bundled sandbox tenant on Base mainnet. Call status first; it always tells
you which mode you're in.
The shared sandbox goes live with the package's first publish — the bundled
sandbox-config.jsonis provisioned (a real tenant created and funded, per docs/deploy/mcp-sandbox.md) before0.1.0ships. If you're running from source before that happens, every tool —statusincluded — refuses outright with the same clean message ("sandbox not provisioned yet"), rather than one guarded verb failing loud and another failing on the empty placeholder key with a cryptic error. Not something you'll hit against the published package.
Wrap any MCP server (enforcing)
The five tools above are the broker — authority your agents ask about.
wrap is enforcement: run any stdio MCP server behind the proxy and
ungranted tools are denied before the server ever sees them, granted tools
are metered, and revocation lands mid-session.
# one line: bound a server to two tools, 20 uses, one hour
grantor-mcp wrap --tools search,fetch --max-uses 20 --ttl-secs 3600 -- npx some-mcp-server
# or wrap a child you granted (and can delegate/revoke) beforehand
grantor-mcp wrap --child <child_id> -- npx some-mcp-serverWhat the wrapped client sees: tools/list filtered to granted ∩ available;
a denied tools/call answered with an isError result carrying
{allow:false, code, reason} (the model reads why); resources/* and
prompts/* denied by default — pass --allow-resources to let them
through (unmetered; a security posture flag, off on purpose). Everything
else passes through untouched. Denies never decrement the use budget.
wrap also computes check's tool_desc_hash for you: it hashes every
tool descriptor the wrapped server returns from tools/list as it passes
through, caches it by tool name, and supplies it on the matching
tools/call automatically — you never pass tool_desc_hash by hand under
wrap. Re-listing re-hashes, so a server that changes a tool's schema and
is re-listed produces the new hash rather than a stale one. A tool the
proxy has never seen in a tools/list response yields no hash at all,
which cannot satisfy a grantor:tool-desc-v1 pin — fail closed, not a
free pass. See docs/guide/capabilities.md § Pinning a tool
descriptor
for the hash definition and what a pin does and does not defend against.
The five tools
Every tool is described in full to the MCP client at connect time
(inputSchema + a one-line purpose string); this is the short version.
status— broker + tenant snapshot: mode, live on-chain tenant standing, principal registration, child/use counters.status() -> {"mode":"sandbox","tenant_status":"Active","principal_registered":true, "children":{"total":0,"revoked":0,"uses_remaining":0}, "grantor_note":"Shared sandbox tenant. For your own limits..."}grant— give a sub-agent a bounded capability: named tools, a use budget, an expiry. Returns achild_idthe broker holds and metering. An optionaldescriptorsmap (tool name — or"*"for a prefix grant — to its current descriptor from your owntools/list, or an array of accepted descriptors) pins the grant to that exact shape, so it breaks rather than silently drifting if the tool's schema changes later; see docs/guide/capabilities.md § Pinning a tool descriptor.grantor-mcp tools --jsonprints a ready-to-use descriptors file for the running server.grant({tools:["search"], max_uses:3, ttl_secs:3600}) -> {"child_id":"a1b2...","sub":"9f3c...","grants":[...],"exp":1786020000}check— authorize ONE action for a child: real on-chain verification (root key, revocation epoch, tenant billing) + capability match + local use budget. Gate every sub-agent tool call on this. An optionaltool_desc_hashlets the caller supply the canonical hash of the tool descriptor being invoked — required only if the matched grant carries agrantor:tool-desc-v1pin (see docs/guide/capabilities.md), ignored otherwise. You will not normally pass this by hand: underwrapit is supplied for you (below). Called this way — as an MCP tool the client invokes directly —tool_desc_hashis whatever the client sends, i.e. caller-supplied;checktrusts its own client to report it honestly.wrapis the trust-minimized path: it computes the hash itself from descriptors it observed ontools/list, so a caller cannot influence it there.check({child_id:"a1b2...", tool:"search"}) -> {"allow":true,"remaining_uses":2} check({child_id:"a1b2...", tool:"write"}) -> {"allow":false,"code":"CapabilityDenied","reason":"..."} check({child_id:"a1b2...", tool:"search", tool_desc_hash:"3a7f...01"}) -> {"allow":true,"remaining_uses":1} // only if the grant's pin includes this hashdelegate— narrow an existing child's capability onward to a new child (fewer tools, fewer uses, shorter expiry). A tool outside the parent's grant is refused at signing; a widermax_uses/ttl_secsis silently clamped to the parent's own bound instead of refused. Either way, the chain walk re-checks narrowing cryptographically at verify.delegate({parent:"a1b2...", tools:["search"], max_uses:1}) -> {"child_id":"c3d4...","sub":"7e21...","grants":[...]}revoke— revoke a child's authority. With your own tenant + admin key this is a real on-chainbumpEpoch; in the sandbox it revokes at the broker (which holds the key and is the check-point).revoke({child_id:"a1b2..."}) -> {"revoked":"onchain","tx":"0x...","epoch_label":"..."}
grant/delegate accept an optional to (an external holder's deed
sub) and return a signed link instead of a broker-held child — for
handing authority to an identity the broker does not hold the key for. See
The presented-deed caveat.
The sandbox honesty block
The bundled sandbox-config.json ships a real, published principal
private key, registered as an agent key on a real Base mainnet tenant, so
grant/delegate/check/revoke all work with zero setup. Before you
build anything on it, know exactly what it is:
- It controls nothing outside the demo tenant. The key is not an admin key on anything, holds no funds, and cannot register or revoke agent keys on-chain — its ONLY on-chain power is the one it's registered for (delegation root for this one tenant).
- No funds, ever. The sandbox tenant's billing is the operator's; you never pay for a sandbox call, and the sandbox never asks for a key that could spend anything.
- Anyone can read this key. It's checked into the published package. Never reuse it, never fund it, and never treat a deed minted with it as private — every sandbox principal in the world is the same identity.
check's self-issued challenge. In broker-held mode, the broker mints a random challenge, signs a deed against it with the child's key, and immediately verifies that same deed — it is both holder and verifier in one process. That's real cryptographic + on-chain verification (the chain reads, the revocation-epoch check, and the billing gate are all live), just not a caller-issued challenge. See the presented-deed caveat for the mode that is.- Metering is broker-local — and, within one broker, atomic and
conserving.
max_uses/remaining_useslive in this process's state file (~/.grantor-mcp/state.jsonby default), not on-chain. Inside one broker process the meter holds two invariants:checkreserves the use in the same synchronous step as the balance gate (two concurrent last-use calls can never both pass; a denied call refunds; a crash between authorization and the tool's side effect loses a use — it can never double-spend one), anddelegateTRANSFERS uses from the parent's live pool (a tree of delegations can never hold more aggregate uses than the root grant; a drained parent refuses to delegate). Across processes the meter is NOT shared or atomic: run one broker per state file.to-mode links (external holders) carry no broker meter at all — their bound is the signed per-pathmax_usescaveat alone.
None of this is a limitation you have to accept — it's what changes the moment you run your own tenant.
Own-tenant setup
Point the broker at your own registered tenant instead of the shared sandbox:
{
"mcpServers": {
"grantor-mcp": {
"command": "npx",
"args": ["-y", "@grantor/mcp", "serve"],
"env": {
"GRANTOR_MCP_CONFIG": "/absolute/path/to/your-broker-config.json",
"GRANTOR_MCP_PRINCIPAL_KEY": "0x<your registered agent key>",
"GRANTOR_MCP_ADMIN_KEY": "0x<your tenant admin key, for revoke>"
}
}
}
}Env vars, all optional except when the mode they gate needs them:
| Var | Meaning |
|---|---|
| GRANTOR_MCP_CONFIG | Path to a broker config JSON ({rpcUrl, chainId, registry, tenant, ...}) — its presence is what switches the broker from sandbox mode to own mode. |
| GRANTOR_MCP_PRINCIPAL_KEY | The delegation-root key grant signs with. Overrides the config file's principalKey; own-tenant mode requires one or the other. |
| GRANTOR_MCP_ADMIN_KEY | The tenant admin key revoke needs for a real on-chain bumpEpoch. Without it, revoke refuses and prints the exact cast send command to run by hand. |
| GRANTOR_MCP_STATE | Override the broker-held-child state file path (default ~/.grantor-mcp/state.json). |
Becoming a tenant is a handful of on-chain transactions, not a signup form:
register at chaingrantor.com/register.html
(USDC on Base, no account), or run the grantor-onboard kit's create/
enroll verbs to do the same from a script. See
docs/deploy/mcp-sandbox.md for the
operator side of standing up a shared broker deployment (what this
package's own sandbox is), or
docs/guide/mcp-broker.md for the full
product walkthrough.
The presented-deed caveat
check also accepts an externally-presented capability deed:
check({deed, challenge, tool}). This mode needs a caller-paired
challenge — the caller must present the exact challenge value its deed was
signed against, which this simple broker does not issue or track itself
(broker-held mode self-issues its own, as documented above). It exists so a
holder identity the broker does NOT control (minted via grant/delegate
with a to argument) can present its own deed for a one-off check — it is
not a general-purpose relying-party integration.
If you're building a real relying party — an HTTP API, an MCP server with
its own tool surface — verifying deeds presented by arbitrary callers, use
@grantor/verify's
DeedGuard/CapabilityGuard directly. It owns real challenge issuance
and tracking, origin binding, and the full deed lifecycle; this package's
check tool is a convenience for a broker managing its OWN children, not a
substitute.
DENY codes
check never throws — every refusal comes back as
{allow: false, code, reason}:
| Code | What it means |
|---|---|
| CapabilityDenied | The deed is genuine and the caller is authenticated, but no grant covers this (tool, action) — wrong resource, or a failed caveat. |
| UsesExhausted | This child's local use budget (broker-metered max_uses) is spent. |
| EpochRevoked | The chain's live delegationEpoch no longer matches what this link was signed against — a bumpEpoch (real or, in the sandbox demo, pre-bumped) invalidated it. |
| BadDelegation | The delegation chain itself doesn't check out — a link widened past its parent, a signature didn't recover, or the chain's root isn't a currently enrolled agent key. |
| TenantInactive | The tenant is neither Active nor Grace on-chain — the billing gate. |
| RevokedLocally | This specific child was revoked at this broker (a fast local short-circuit, checked before any chain read). |
| UnknownChild | No broker-held child with that child_id — check the ID, or that you're pointed at the state file the grant call used. |
| BadRequest | Malformed check call — neither child_id nor both deed and challenge were supplied. Every field on check's schema is optional, so this is reachable from a real MCP call, not just a hand-built one. |
| Chain | An on-chain read failed (unreachable RPC, etc.) — fails closed, never allows. |
| ResourceDenied | (wrap only, JSON-RPC error -32001) — resources/*/prompts/* are denied by default under wrap; relaunch with --allow-resources to pass them through. |
See docs/guide/errors.md for the full verifier-wide table these are drawn from.
Also usable from the CLI
Every tool is also a CLI verb, for scripting outside an MCP client:
node src/cli.js grant --tools search --max-uses 3 --ttl-secs 3600
node src/cli.js tools --json -- npx some-mcp-server > descriptors.json # produce a descriptors file for the line below
node src/cli.js grant --tools search --descriptors descriptors.json --max-uses 3 --ttl-secs 3600 # pin the grant to that exact shape
node src/cli.js check --child <child_id> --tool search
node src/cli.js delegate --parent <child_id> --tools search --max-uses 1
node src/cli.js revoke --child <child_id>
node src/cli.js status
node src/cli.js demo # the first denial, scripted, in this terminal
node src/cli.js tools -- npx some-mcp-server # list a server's tools + suggest a wrap--descriptors is a grant-only flag — every verb has its own accepted
flag set (VERB_FLAGS in src/cli.js), and an unrecognized flag now
refuses, naming the flag, the verb, and (when one exists) the verb that
does accept it. delegate --descriptors ... errors rather than silently
minting an unpinned child: delegate has no --descriptors flag at
all — it doesn't need one, since it inherits a pinned parent grant's nb
predicates onto the child automatically (see
docs/guide/capabilities.md).
License
SEE LICENSE IN LICENSE.
