# @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)](#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](#sandbox-vs-your-own-tenant) for exactly what
that key can and can't do.

## 30 seconds: watch a denial happen

```sh
npx -y @grantor/mcp demo
```

Wraps 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:

```sh
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:

```sh
claude mcp add grantor-mcp -- npx -y @grantor/mcp serve
```

Or wire it into any MCP client's config directly:

```json
{
  "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.json` is provisioned (a real tenant created and
> funded, per [docs/deploy/mcp-sandbox.md](../../docs/deploy/mcp-sandbox.md))
> before `0.1.0` ships. If you're running from source before that happens,
> every tool — `status` included — 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.

```sh
# 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-server
```

What 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](../../docs/guide/capabilities.md#pinning-a-tool-descriptor-grantortool-desc-v1)
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 a `child_id` the broker holds and metering. An
  optional `descriptors` map (tool name — or `"*"` for a prefix grant — to
  its current descriptor from your own `tools/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](../../docs/guide/capabilities.md#pinning-a-tool-descriptor-grantortool-desc-v1).
  `grantor-mcp tools --json` prints 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 optional
  `tool_desc_hash` lets the caller supply the canonical hash of the tool
  descriptor being invoked — required only if the matched grant carries a
  `grantor:tool-desc-v1` pin (see
  [docs/guide/capabilities.md](../../docs/guide/capabilities.md#pinning-a-tool-descriptor-grantortool-desc-v1)),
  ignored otherwise. You will not normally pass this by hand: under `wrap`
  it is supplied for you (below). **Called this way — as an MCP tool the
  client invokes directly — `tool_desc_hash` is whatever the client sends,
  i.e. caller-supplied; `check` trusts its own client to report it
  honestly.** `wrap` is the trust-minimized path: it computes the hash
  itself from descriptors it observed on `tools/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 hash
  ```
- **`delegate`** — 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 wider `max_uses`/`ttl_secs` is
  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-chain `bumpEpoch`; 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-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](#the-presented-deed-caveat) for the mode that
  is.
- **Metering is broker-local — and, within one broker, atomic and
  conserving.** `max_uses`/`remaining_uses` live in this process's state
  file (`~/.grantor-mcp/state.json` by default), not on-chain. Inside one
  broker process the meter holds two invariants: `check` reserves 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), and `delegate` TRANSFERS 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-path `max_uses` caveat 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:

```json
{
  "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](https://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](../../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](../../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`](https://www.npmjs.com/package/@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](../../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:

```sh
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](../../docs/guide/capabilities.md#pinning-a-tool-descriptor-grantortool-desc-v1)).

## License

SEE LICENSE IN LICENSE.
