◐ Docs

MCPIP Documentation

Connect an agent to a governed gateway, stand one up yourself, or look anything up — every part, and every way in, is a numbered step on this page.

browse the docs

Docs / Get started

Connect an agent#

You have an agent — MCP, REST, or an SDK — and need it to call governed tools through a gateway someone already runs.

7 steps · token · connect · authorize

shortcut · agent setup

Skip ahead — point a coding agent at one URL

Hand this to Claude Code, Codex, Cursor, or any coding agent and it will do the steps below for you: connect an agent to your gateway and build your company's skill catalog. The prompt is grounded in the same API this guide documents.

paste this to your agent
Fetch https://mcpip.ai/agent-setup/prompt.md and follow it.

Prefer to read it yourself? curl -s https://mcpip.ai/agent-setup/prompt.md — or open the agent-setup page.

01

Install & set up#

Two things run today from a fresh clone, no registry, no signup: the one-command sandbox, and the mcpip CLI straight from git. The published @mcpip/sdk / mcpip-sdk packages ship with the public release — they are marked as such below.

the blessed front door — clone + one command → sandbox gateway + walkthrough
git clone https://github.com/mcpip-security/mcpip.git && cd mcpip
./scripts/quickstart.sh

Idempotent: it installs Redis if missing, creates a venv, boots the gateway on :8080 in sandbox mode, and runs the mcpip-inc walkthrough. Only prerequisite is Python 3.12. Self-contained — no external IdP, no vendor cloud.

02

Get a token#

MCPIP is identity-sovereign: it only ever verifies a JWT, it never mints one. In sandbox the in-process sandbox IdP mints a short-lived token so the walkthrough is runnable; in production your own IdP issues agent tokens and this endpoint does not exist.

POST/v1/dev/token
FieldTypeDescription
tenant_idstringTenant the agent acts under. Defaults to the sandbox tenant (tenant-acme).
agent_idstringAgent id recorded on the minted token. Defaults to agent-orchestrator-1.
rolestringDescriptive label ONLY — it authorizes nothing. Defaults to ops.
compartmentuuid | nullOptional compartment UUID this principal is scoped to (team/MCP separation).
capabilitiesuuid[] | nullOptional capability UUIDs. The well-known admin/audit caps are at GET /v1/dev/capabilities.
mint a sandbox JWT — the response is just { jwt }
TOKEN=$(curl -s localhost:8080/v1/dev/token \
  -H 'content-type: application/json' -d '{}' | jq -r .jwt)

Sandbox only. This endpoint returns 404 whenever MCPIP_SANDBOX_MODE=false (the production default) — the minter simply does not exist. A sandbox token lives ~5 minutes; after that every call is an opaque deny that looks like a broken connection but is just the lapsed token.

present it on every subsequent request — header only
Authorization: Bearer <jwt>     # never a URL, never a query string, never a log line

In production a real agent token is issued out-of-band by your IdP (the analog of /v1/dev/token is scripts/mint_principal.py, signed with your IdP key). Confirm what a token actually carries with GET /v1/whoami — it echoes the verified tenant, agent, compartment, and effective capabilities, so you never have to diagnose entitlements through opaque denies.

03

Connect your agent#

Two edges, one pipeline. The gateway is the MCP server (POST /v1/mcp), and the REST edge (POST /v1/authorize) speaks seven declared source formats. The tools/list / catalog you receive is already pruned to your identity — another team’s skills are invisible, not merely forbidden.

The repo ships a project .mcp.json registering a self-minting, auto-refreshing stdio bridge (scripts/claude_mcp_bridge.py). Run claude inside the repo and approve the mcpip server — the bridge mints the sandbox token itself and refreshes it before expiry, so a long session keeps working.

.mcp.json (as shipped)
{
  "mcpServers": {
    "mcpip": {
      "command": "python3",
      "args": ["scripts/claude_mcp_bridge.py"],
      "env": {
        "MCPIP_URL": "http://localhost:8080",
        "MCPIP_TENANT": "mcpip-inc",
        "MCPIP_AGENT": "anthropic-claude-1"
      }
    }
  }
}

In production set MCPIP_TOKEN to a JWT your IdP issued; the bridge attaches it verbatim and never mints — identity stays with your IdP.

04

Authorize a tool call#

POST /v1/authorize is the one choke point. One request authorizes exactly one tool call, and the outcome is one of three shapes: 200 executed, 202 staged behind a step-up, or 403 an opaque deny. The response never reveals the real target — only a coarse transport class.

POST/v1/authorize
FieldTypeDescription
source_formatrequiredenumOne of openai_tool_call, anthropic_tool_use, gemini_function_call, bedrock_tool_use, mcp_jsonrpc, raw_mcp, a2a_task. Supply exactly one of source_format / vendor.
vendorstringAlternative to source_format — a declared vendor id (e.g. openai, gemini, bedrock) resolved through a hash-pinned registry. An unknown vendor is an opaque 403.
tool_callrequiredobjectThe raw provider envelope, carrying the skill_* alias and its arguments. Deep-validated by the Bridge (depth / size / charset caps; an identity-shaped key is a hard deny).
jwtstring | nullOptional — identity is normally taken from the Authorization: Bearer header instead.
pin / challenge_idstring | nullThe step-up completion pair (step 05). Supplied together, never one alone.
200 · ExecutionReceipt — executed & WORM-logged before dispatch
{
  "correlation_id": "…",
  "decision": "allow",
  "status": "committed",
  "transaction_ref": "txn_…",
  "executed_target_class": "cloud_rest",   // coarse CLASS, never the real target
  "worm_sequence": 4213
  // "vended_credential": {…}  present ONLY for a cloud_iam skill
}
202 · StagedChallenge — a PIN_REQUIRED alias, no data yet
{
  "correlation_id": "…",
  "action_required": "approve in the enrolled authenticator",
  "challenge_id": "…",          // the payload-bound lock id (step 05)
  "risk_tier": "pin_required"
}
403 · ErrorResponse — opaque, always
{ "error": "MCPIP: request denied by policy.", "correlation_id": "…" }

Need only a verdict, not execution? POST /v1/authz/decision answers as an OpenID-AuthZEN PDP — { decision: bool } — and never executes, vends, or stages.

05

Clear a step-up#

A 202 means a human must approve this exact payload. The one-time PIN is delivered out-of-band to the enrolled authenticator; you complete the call by re-POSTing the identical tool call plus the pin and challenge_id. Change one byte of the payload and it no longer matches.

GET/v1/authenticator/{challenge_id}

Sandbox only. Stands in for the enrolled device by returning the staged one-time code ({ challenge_id, otp }). Requires a valid JWT and is 404 in production — where the PIN is pushed to your authenticator webhook and read by a human (below).

complete the step-up — the IDENTICAL tool_call plus pin + challenge_id
# Stage a step-up first — this is what produces CHALLENGE_ID. skill_email_send is
# pin_required, so it answers 202 rather than the 200 the step 03 example returns.
CHALLENGE_ID=$(curl -s localhost:8080/v1/authorize -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{
  "source_format":"openai_tool_call",
  "tool_call":{"id":"c1","type":"function","function":{"name":"skill_email_send",
    "arguments":"{\"to\":\"board@mcpip-inc.example\",\"subject\":\"Q3 report\"}"}}}' \
  | jq -r .challenge_id)

OTP=$(curl -s localhost:8080/v1/authenticator/$CHALLENGE_ID \
  -H "authorization: Bearer $TOKEN" | jq -r .otp)   # sandbox peek

curl -s localhost:8080/v1/authorize -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d "{
  \"source_format\":\"openai_tool_call\",
  \"tool_call\":{ …the SAME envelope… },
  \"pin\":\"$OTP\", \"challenge_id\":\"$CHALLENGE_ID\"}"
# → 200 ExecutionReceipt. Replay the spent triple → 403 (consumed exactly once).
SDK — .complete(staged, pin)
from mcpip_sdk import Staged

# skill_email_send is pin_required AND visible to the token minted in step 02.
result = client.authorize("skill_email_send",
                          {"to": "board@mcpip-inc.example", "subject": "Q3 report"})

# authorize() returns Allowed | Staged — branch on the type, which is the SDK's
# actual contract. There is no .is_staged attribute.
if isinstance(result, Staged):             # 202 — result.challenge_id came back
    receipt = client.complete(result, pin=one_time_code)   # same payload + pin → 200

The PIN is bound to sha256(canonical_json(tenant, agent, alias, arguments)) and consumed by a single atomic Redis operation — one byte of drift is payload_mismatch, and the lock survives a correct retry. In production the human who reads the delivered code proves possession of a per-user RFC 6238 TOTP authenticator (POST /v1/authenticator/reveal) — the human 2FA that gates reading the code, distinct from the payload-bound PIN itself.

06

Read the receipt#

A 200 is an ExecutionReceipt. Branch on it, quote its worm_sequence to an operator, and note that topology never crosses the boundary — you learn the transport class, never the dotted target.

FieldTypeDescription
correlation_idstringThe one handle an agent may quote to a human operator to locate this decision in the audit log.
decisionstringAlways "allow" on a 200 (a deny never reaches this shape).
statusstringAlways "committed" — the WORM ALLOW record was written before dispatch.
transaction_refstringtxn_ + uuid4 — a per-execution reference.
executed_target_classstringcloud_rest | legacy_mainframe | cloud_iam — the coarse transport class only. Never entry.target.
worm_sequenceintThe audit anchor the operator can quote to find the sealed decision record.
vended_credentialobject | nullPresent ONLY for a cloud_iam skill: the short-lived, scoped cloud credential vended for THIS call. The agent’s deliverable — never persisted to WORM.

In the SDK the receipt is a frozen result object — read correlation_id, transaction_ref, and worm_sequence directly; a cloud_iam receipt also carries vended_credential.

07

Handle denials#

The boundary is fail-closed and opaque. Whatever the real reason, a policy denial gives the caller only the generic message plus a correlation_id — so the gate can never be turned into an oracle. The concrete reason lives only in the WORM log, for the operator.

OutcomeClassWhat the caller sees — and what to do
403 · ErrorResponsepolicyEvery policy denial — cross-tenant, compartment, capability, PIN mismatch, canary, revocation, disabled skill, and dozens more — collapses to the identical { error: "MCPIP: request denied by policy.", correlation_id }. Do not retry; quote the correlation_id to an operator.
422 · invalid requestschemaA malformed envelope (neither/both of source_format & vendor, a bad tool_call, or arguments over the depth/size/charset caps). Fix the request shape — this is rejected at the door before any engine work.
202 · not an errorstep-upA StagedChallenge is a success path, not a failure — complete it with the payload-bound PIN (step 05).
SDK · MCPIPDeniedclientBoth SDKs raise a single opaque deny carrying only a correlation_id, and never auto-retry. Secrets (JWT, OTP, vended credentials) never touch stdout/argv/logs.

The reason your call was denied is real and recorded — it just lives operator-side. The full deny-reason taxonomy (WORM-only) is in the reference; an operator resolves your correlation_id against it.

Docs / Administer

Run the gateway#

You operate the gateway: set up the host, verify the signed release, provision keys, register skills, boot fail-closed, and wire monitoring and audit.

9 steps · setup to cutover

Choose your edition#

The steps below are the same everywhere — what changes per tier is the distribution, the license, and the availability posture. Connecting an agent (the Get started track) is identical across all of them. Commercials live on pricing.

Free forever. The gateway core is source-available (BSL 1.1); the SDKs are Apache-2.0. Self-host it single-node — ideal for evaluation and non-critical fleets. Sandbox mode needs no license.

run the open core in sandbox, single-node
git clone https://github.com/mcpip-security/mcpip.git && cd mcpip
MCPIP_SANDBOX_MODE=true docker compose up --build
# then follow the steps below for a real fail-closed production boot
01

Set up the host#

The gateway runs entirely inside your perimeter — a stateless process plus a durable Redis, and no vendor cloud. Provision a container host (or a cluster) and the Redis the audit chain depends on.

a container runtime OR Kubernetes — pick your platform
# any OCI runtime
docker --version            # Docker (docker compose for single host)
podman --version            # or Podman (rootless-friendly)

# or a cluster
kubectl version --client    # Kubernetes (chart/ or k8s/)
helm version                # Helm, to install the chart (step 09)

TLS is terminated upstream (ingress / service mesh / identity-aware proxy); the gateway itself serves plain HTTP inside the perimeter — there are no in-process cert/key settings.

02

Verify the release#

Nothing is trusted on faith. The release ships an offline-root-signed manifest and a boot-integrity manifest; the release verifier re-checks both with pure local Ed25519 — no network, no PKI, no phone-home. Gate every deploy on exit code 0.

Two different packages install a command called mcpip. sdk/python — the one install.sh and the quickstart give you — provides the agent CLI (login, authorize, catalog…). The root package provides the release verifier. Running mcpip verify against the SDK CLI fails with invalid choice: 'verify' and exit 2, so the commands below use python -m mcpip_verify.cli, which is unambiguous whichever mcpip is on your PATH.

the release verifier — read-only, fails closed, exit 2
# from a checkout — unambiguous regardless of which mcpip is on PATH
python -m mcpip_verify.cli verify --manifest release/manifest.json \
  --pubkey release/keys/release_root_ed25519.pub.pem --base-dir .

# independently re-verify the tamper-evident WORM chain (Merkle + Ed25519)
python -m mcpip_verify.cli export-audit \
  --redis-url "$MCPIP_REDIS_URL" --out audit_export.jsonl \
  --verify --pubkey worm_signing_ed25519.pub.pem

# --base-dir must hold the artifacts the manifest lists. The wheel and sdist are
# release assets, not committed, so a bare checkout exits 2 on their absence.

The same integrity manifest is re-hashed at boot: the running process re-checks every shipped source file against MCPIP_INTEGRITY_MANIFEST_PATH before a socket is bound, and refuses to start on any mismatch. There is no self-update and no self-heal — an upgrade is a signed redeploy by immutable digest.

03

Provision secrets & keys#

Config is env-driven (prefix MCPIP_), resolved once at boot into an immutable settings object. With MCPIP_SANDBOX_MODE=false (the default) the boot sequence fails closed on any missing key material — it refuses to start rather than run in a forgeable posture.

You generate two Ed25519 keypairs the vendor never holds (scripts/provision_gateway_keys.py): a WORM epoch-signing key (MCPIP_WORM_SIGNING_KEY_PATH, gateway-held) and your IdP’s public key (MCPIP_JWT_PUBLIC_KEY_PATH, verify-only — the IdP private key never touches the gateway). The signed license (gen_license.py) and the release/license-root public keys round out the set. The complete boot surface is the environment variable reference.

the key ceremony (run on an offline signer / into a KMS)
python scripts/provision_gateway_keys.py --keys-dir <offline> --public-dir <staging>
#  worm_signing_ed25519.key  -> MCPIP_WORM_SIGNING_KEY_PATH  (private, gateway-held)
#  worm_signing_ed25519.pub  -> auditors  (python -m mcpip_verify.cli export-audit --verify --pubkey ...)
#  idp_signing_ed25519.key   -> your token minter / KMS  (NEVER the gateway)
#  idp_signing_ed25519.pub   -> MCPIP_JWT_PUBLIC_KEY_PATH  (public, gateway verifies)
04

Register skills#

A skill binds an opaque alias the agent sees (e.g. skill_aws_s3) to a hidden target it never sees, with a permission model and a risk tier. Register additive-only via the admin API (or the operator console); a config alias can never be shadowed.

POST/v1/admin/skills/register

Requires CAP_DIRECTORY_ADMIN (a capability, never a role). Transport is cloud_rest only; a RESTRICTED skill must be pin_required. Name aliases skill_{platform}_{tool} — risk is a per-alias data field, never a name prefix.

FieldTypeDescription
aliasrequiredstringThe opaque agent-facing name, e.g. skill_aws_dynamodb, skill_spend_summary.
targetrequiredstringThe real, hidden target the agent never sees. cloud_rest only.
servicestring | nullAdvisory display label for the permission-model console view (e.g. "AWS DynamoDB"). Never an enforcement input.
access"read" | "write"Advisory access mode for the console permission model. Never an enforcement input.
risk_tier"auto" | "pin_required"auto executes immediately; pin_required stages a payload-bound step-up. Defaults to auto.
classificationstringunclassified | restricted — display/annotation only (defaults to unclassified; restricted ⇒ pin_required). The classified tier is seeded-config only, not accepted by this endpoint.
register a new alias → target (additive-only)
# $ADMIN is used by every admin call below. In sandbox, mint one holding
# CAP_DIRECTORY_ADMIN (in production your IdP issues it — the gateway never mints):
mcpip --context sbx sandbox dev-token --tenant mcpip-inc --agent ops-1 \
  --cap b8e4a1d7-2c6f-4e93-9a05-7f1c3b5d8e20 --out /tmp/admin.jwt
export ADMIN=$(cat /tmp/admin.jwt)

curl -s localhost:8080/v1/admin/skills/register -H "authorization: Bearer $ADMIN" \
  -H 'content-type: application/json' -d '{
  "alias":"skill_my_first_alias",
  "target":"aws.dynamodb.orders-table",
  "service":"AWS DynamoDB", "access":"read",
  "risk_tier":"auto", "classification":"unclassified"}'
# → {"registered":"skill_my_first_alias"}   (a NAME NOT already in the catalog —
#    re-registering a seeded alias is refused, additive-only by design)
05

Boot the gateway#

Run it non-root, read-only, inside your perimeter. With sandbox mode off the boot fails closed unless every gate passes — integrity manifest, license, WORM/JWT keys, and the Redis durability posture — all before a socket is bound.

single-host production — docker-compose.prod.yml (bundled durable Redis)
# place the SIX files in ./secrets (never commit): the IdP public key, the WORM
# signing key, license.json, integrity_manifest.json, and the release/license
# root public keys. Then a .env beside the compose file — it hard-requires both
# of these and refuses to interpolate without them (compose fails before any
# container starts, naming the missing variable):
printf 'MCPIP_JWT_ISSUER=https://idp.example.com/
MCPIP_JWT_AUDIENCE=mcpip-gw.example
' > .env

docker compose -f docker-compose.prod.yml up --build
curl -s http://localhost:8080/healthz     # {"status":"live","glyph":"◐",...}
curl -s http://localhost:8080/readyz      # {"status":"ready","redis":"up"}

# for a sandbox eval instead: the one-command ./scripts/quickstart.sh, or the
# manual bring-up below
sandbox bring-up — the exact verified sequence (from a repo checkout, Python 3.12)
python3.12 -m venv .venv
.venv/bin/pip install -r requirements.txt
redis-server --port 63790 --daemonize yes --appendonly yes --appendfsync always
MCPIP_SANDBOX_MODE=true MCPIP_REDIS_URL=redis://localhost:63790/0 \
  .venv/bin/python -m uvicorn app.main:app --port 8080 &
curl -s http://localhost:8080/healthz      # {"status":"live","version":"3.0.0"}
curl -s http://localhost:8080/readyz       # {"redis":"up"}

With the gateway up in sandbox mode, connect an agent and run the governed calls from Get started (the CLI tab): the verified connect → authorize → step-up → sandbox audit verify sequence. Production drops sandbox mode and swaps the dev-token minter for your IdP (scripts/mint_principal.py).

Boot gateEnvBehavior on failure
Verified bootINTEGRITY_MANIFEST_PATH + _PUBLIC_KEY_PATHRe-hashes every shipped source file against the signed manifest; any mismatch is an opaque "integrity verification failed" and a nonzero exit. No self-heal — redeploy.
LicenseLICENSE_PATH + _PUBLIC_KEY_PATHEd25519-signed entitlement, checked at boot only (never per request). Expired / tampered → refuses to start.
WORM durabilityREDIS_URL (AOF)Refuses to boot unless Redis is appendfsync always — the write-before-execute ordering guarantee.
Sender-constraint lintcatalogRefuses to boot if any RESTRICTED/CLASSIFIED non-pin_required alias lacks require_sender_constraint (a bearer could otherwise read it).
Authenticator webhookAUTHN_WEBHOOK_URL + _SECRET_PATHRequired together for any PIN_REQUIRED skill; setting exactly one is a fail-closed boot error. AUTO-only deployments leave both unset.
06

Operators & 2FA#

The operator/team roster is a management surface — the role label authorizes nothing; capabilities, compartments, and grants do. Agents get their own IdP-minted JWTs. Humans who clear step-ups enroll a per-user RFC 6238 authenticator.

POST/v1/admin/users/invite

Invite a new operator by email (CAP_DIRECTORY_ADMIN). Additive-only; returns the record plus a one-time invite reference (stored only as a hash). The role admin / member / viewer — is a label that authorizes nothing.

invite an operator
curl -s localhost:8080/v1/admin/users/invite -H "authorization: Bearer $ADMIN" \
  -H 'content-type: application/json' -d '{"email":"ops@acme.example","role":"member"}'
# → 201 {"user":{...},"invite_token":"…once…"}
GET/v1/admin/users

The rest of the roster lifecycle. Both fields are closed enums — an unknown value is a 400 naming what is allowed, not a silent default:

FieldValuesNotes
roleadmin · member · viewerA management label for the operator’s own bookkeeping and for their IdP/SSO to honor when it issues tokens. The authorize path NEVER reads it. Not to be confused with the free-text `role` claim on a minted principal, which is also descriptive but is not an enum.
statusinvited · active · disabledThe lifecycle. An invite lands as `invited`; the person becomes `active` once they authenticate through your IdP. `disabled` keeps the record and the audit trail while removing them from the roster — prefer it to DELETE unless you mean to erase the row.
list · update role or status · remove
# list the roster
curl -s localhost:8080/v1/admin/users -H "authorization: Bearer $ADMIN"

# change a role, or disable without deleting (at least one field required)
curl -s -X PUT localhost:8080/v1/admin/users/ops@acme.example \
  -H "authorization: Bearer $ADMIN" -H 'content-type: application/json' \
  -d '{"status":"disabled"}'

# remove the record entirely
curl -s -X DELETE localhost:8080/v1/admin/users/ops@acme.example \
  -H "authorization: Bearer $ADMIN"

The roster authenticates nobody and mints no session. An invite produces a shareable reference token, stored only as a hash — not a credential. The invited person still authenticates through your configured IdP, and every change here is written to WORM like any other privileged action.

POST/v1/authenticator/enroll

Per-user 2FA gates reading a delivered step-up code. Gated by MCPIP_AUTHN_TOTP_KEY_PATH — absent, every authenticator surface is an opaque 404. Enroll returns the provisioning material exactly once; confirm proves possession of a live code.

enroll → confirm (RFC 6238 TOTP — SHA-1 / 6 digits / 30 s)
# 1) begin — returns { secret, provisioning_uri (otpauth://), digits, period_s } ONCE
curl -s -X POST localhost:8080/v1/authenticator/enroll -H "authorization: Bearer $JWT"
# 2) confirm a live code from the app → activates the enrollment
curl -s -X POST localhost:8080/v1/authenticator/enroll/confirm \
  -H "authorization: Bearer $JWT" -H 'content-type: application/json' -d '{"code":"123456"}'
# → {"enrolled":true}

Agent identities are minted by your IdP, not MCPIP — the production analog of the sandbox minter is scripts/mint_principal.py, scoping an agent to a tenant plus capability/compartment entitlements the gateway enforces.

07

Delegate sessions#

Orchestrators spawn workers. Delegation makes the hand-off governed: a session registers a grant for a child session holding a strict subset of its own authority — capabilities ⊆ its effective set (refused otherwise, never silently intersected), compartment same-or-narrower, expiry capped by the parent's, depth ≤ 4. The authorize path then intersects the child's JWT with the grant, and revoking any session kills its whole subtree. Off by default: enable with MCPIP_DELEGATION_ENABLED=true — without it the surface answers 404, and a token carrying a delegation_id claim is denied fail-closed rather than silently un-narrowed.

POST/v1/delegate
the whole ceremony, in sandbox — grant, narrowed child, cascade revoke
# boot the sandbox with delegation on
MCPIP_SANDBOX_MODE=true MCPIP_DELEGATION_ENABLED=true MCPIP_REDIS_URL=redis://localhost:63790/0 \
  ./.venv/bin/uvicorn app.main:app --port 8080

# a dispatcher session (session_id is the verified claim delegation binds to)
PSID=$(python3 -c 'import uuid;print(uuid.uuid4())')
CSID=$(python3 -c 'import uuid;print(uuid.uuid4())')
PARENT=$(curl -s localhost:8080/v1/dev/token -H 'content-type: application/json' \
  -d "{\"tenant_id\":\"tenant-acme\",\"agent_id\":\"agent-dispatcher\",\"session_id\":\"$PSID\",\"capabilities\":[\"b8e4a1d7-2c6f-4e93-9a05-7f1c3b5d8e20\"]}" | jq -r .jwt)

# grant the child session NOTHING extra (empty caps ⊆ anything)
DID=$(curl -s localhost:8080/v1/delegate -H "authorization: Bearer $PARENT" \
  -H 'content-type: application/json' \
  -d "{\"child_agent_id\":\"agent-worker\",\"child_session_id\":\"$CSID\",\"capabilities\":[],\"expires_in_s\":300}" | jq -r .delegation_id)

# the child's token CLAIMS the admin cap — the grant strips it at the gateway
CHILD=$(curl -s localhost:8080/v1/dev/token -H 'content-type: application/json' \
  -d "{\"tenant_id\":\"tenant-acme\",\"agent_id\":\"agent-worker\",\"session_id\":\"$CSID\",\"capabilities\":[\"b8e4a1d7-2c6f-4e93-9a05-7f1c3b5d8e20\"],\"delegation_id\":\"$DID\"}" | jq -r .jwt)
curl -s -o /dev/null -w '%{http_code}' localhost:8080/v1/admin/users -H "authorization: Bearer $CHILD"

# cascade: revoking the child session kills it (and any grants IT made)
curl -s localhost:8080/v1/delegate/revoke -H "authorization: Bearer $PARENT" \
  -H 'content-type: application/json' -d "{\"session_id\":\"$CSID\"}"

The 403 above is the whole point: the child's JWT claims CAP_DIRECTORY_ADMIN, but its effective authority is the intersection with the grant — which handed down nothing. Registration needs no capability, deliberately: a grant can only narrow the caller's own authority, so a dispatcher agent is a normal caller, not an admin event. Every grant is WORM-sealed before it goes live, the console renders the lineage under Principals, and in production the gateway still mints nothing — the child's identity comes from your IdP; the gateway only ever subtracts.

08

Monitor & audit#

Denials are opaque to the agent but concrete in WORM — the operator sees everything the attacker cannot. Point Prometheus at /metrics, gate traffic on /healthz + /readyz, and verify the tamper-evident chain.

ConcernSurfaceNotes
Liveness / readinessGET /healthz · /readyz/healthz reports the running version; /readyz is gated on Redis. Both fail closed.
MetricsGET /metricsDecision + latency counters, Prometheus exposition. Closed-enum labels only — no tenant/agent/alias/deny_reason, so it can’t be scraped as a deny oracle.
Decision feedGET /v1/admin/decisions/recentA tenant-scoped whitelist projection of the WORM tail (alias, decision, deny_reason, class, correlation — and session attribution: every row names WHICH session of the agent made the call, when the token carries the verified session_id claim). CAP_DIRECTORY_ADMIN.
Tamper-evidencea separate capabilityVerifying the chain is CAP_FORENSIC_READ work, not CAP_DIRECTORY_ADMIN work — the operator cannot read a forensic capture, and the auditor cannot read this decision feed. See Audit the ledger.
Consoledashboard :5173The operator console renders the live decision feed, the WORM ledger, and the honest dark-feature posture from /v1/admin/stats.

The durable audit record is the Redis-backed, signed Merkle-epoch WORM chain — written before dispatch — not a stdout log line. Every protected request lands as one of three decisions with a WORM-only reason code; the full taxonomy is the WORM decision reference. Structured JSON logs still go to stderr for your SIEM, but the WORM ledger is the authoritative evidence.

09

Operator console#

The console is how a human sees what the gateway decided. It is a read-only observer of state the gateway already committed — it never sits in the authorization path, holds no secret, and mints no credential. Everything below is the real console against a live gateway.

MCPIP GATEWAY — ONE PROCESS, STATELESS, FAIL-CLOSEDAgentany of 7 dialectsBridgenormalizeObfuscatoralias → targetAuthJWT + PINAuditWORM writeYour systemexecuteswritten BEFORE executeSigned WORM ledgerEd25519 Merkle epochsread-only projectionOperator consoleobserves · never gatesThe console is OUTSIDE the decision path. Close it, break it, never install it —every authorization decision is unchanged. That is the property being shown.

That placement is the whole design. The console talks to the same admin endpoints you can curl, over the same CAP_DIRECTORY_ADMIN credential, and renders the WORM projection the gateway wrote before dispatch. There is no console database and no second source of truth: if the console and the ledger ever disagreed, the ledger is right and the console has a bug.

The MCPIP operator console Monitor view: decisions-per-second, decisions since start, gateway p50, audit-chain status, readiness and catalog counters above a live decision stream.
Monitor → Live. A real gateway after 33 decisions — 23 allowed, 5 denied, 5 staged for step-up. Every tile is a live read: AUDIT CHAIN · Intact is a fresh verify_chain verdict at seq #38, not a cached badge, and READINESS mirrors /readyz— it turns red the moment Redis goes away, because the gateway fails closed rather than serve on a degraded ledger. Selecting a row opens the inspector on the right: correlation id, WORM event id, sequence number, source dialect, transport, risk tier. That is the ledger’s own projection of the row — note what is absent, because the console is never shown it: the resolved target and the argument payload.
ViewTabWhat it answers — and what backs it
MonitorLiveIs the gateway healthy, and what is it deciding right now? Backed by GET /readyz, /metrics, /v1/admin/decisions/recent and /v1/audit/attestation. The decision feed is a tenant-scoped whitelist projection — alias, decision, deny reason, class, correlation id. Never the target, the payload, or a secret.
GovernanceSkillsWhich skills exist, who may call them, and what is quarantined? Backed by GET /v1/admin/skills/registered, POST /v1/admin/skills/register, POST /v1/admin/skills/{alias}/disable | /enable, /v1/admin/quarantine and /v1/admin/canaries. Enabling or disabling a skill is a real gateway write, audited to WORM like any other privileged action.
SettingsUsersWho are my operators, and are they enrolled in 2FA? Backed by GET /v1/admin/users and GET /v1/admin/authenticator/enrollments (per-agent: /v1/admin/authenticator/{agent_id}). The role field is a management LABEL — it authorizes nothing; capability UUIDs do.
DevelopersProbeDoes an end-to-end call actually work from here? Fires POST /v1/authorize through the Authorize Probe, alongside copy-ready SDK and CLI snippets. The probe produces a real decision that then appears in Monitor — the loop is the demonstration.
The Governance view listing registered skills with their transport, risk tier and enabled state.
Governance → Skills. The catalog grouped by service, with read/write intent per row. A disabled skill denies at the hot path on the next call — there is no cache to wait out — and what an agent receives from tools/list is pruned to its verified identity, so two agents on the same gateway do not see the same catalog. The rows tagged DECOY are canary aliases: plausible-looking, never legitimately callable, and wired to a tripwire. An agent that reaches for export all credentials is not making a mistake, and the gateway treats it accordingly.
The Developers view showing the Authorize Probe and copy-ready SDK and CLI snippets.
Developers → Probe. The fastest honest check that the whole chain works: fire one authorize, watch it land in Monitor, then pull its inclusion proof. If the probe succeeds and the decision does not appear in the ledger, you have found a real bug — that gap is exactly what write-before-execute exists to make impossible.
The same Monitor view rendered in the console's dark theme.
Both themes are first-class. The console follows your OS preference and can be toggled per-session. Status colours, elevation and focus rings are defined per theme rather than inverted, so contrast holds on either ground — an operator reading a deny reason at 2 a.m. gets the same legibility either way.

Running it

The console is a static bundle — no server, no database, nothing to breach. Point it at a gateway and it renders that gateway’s state; point it at nothing and it says so plainly instead of showing zeros.

Serve the console
cd dashboard
npm install
npm run dev            # http://localhost:5173

# then set the gateway endpoint in the UI, or pre-pin it:
#   localStorage['mcpip.gateway.base'] = 'https://mcpip.internal:8080'

In production the sandbox token minter is 404 by design, so the console cannot mint itself an admin credential — you supply one. When it has none it says “roster unavailable — no admin credential” rather than rendering an empty team and letting you conclude nobody is there. See Operators & 2FA for issuing that credential and Monitor & audit for the endpoints behind every tile.

09

Production cutover#

There is no cutover flag to flip — MCPIP_SANDBOX_MODE defaults to false, so production is fail-closed by construction. The switch toward sandbox mode is the exception, and the boot gates refuse to start unless the production posture is complete.

SettingKindRequirement
MCPIP_SANDBOX_MODEenvKeep false (the default). true mounts the dev-token forge and the OTP peek behind a loud banner — never in production. The sandbox routes (/v1/dev/token, /v1/authenticator/{id}, /v1/audit/verify) 404 in production.
JWT / WORM / integrity / license keysenvMCPIP_JWT_PUBLIC_KEY_PATH, MCPIP_WORM_SIGNING_KEY_PATH, the integrity manifest + key, and the license + key are all hard boot dependencies. Missing or invalid → the process refuses to boot.
MCPIP_REDIS_URLenvMust prove appendfsync always at boot (assert_persistence_posture). No env skips the check — the WORM ordering guarantee is the product.
MCPIP_AUTHN_WEBHOOK_URL / _SECRET_PATHenvBoth required for any PIN_REQUIRED skill (out-of-band OTP delivery). Setting exactly one is a fail-closed boot error; AUTO-only deployments leave both unset.
Sender-constraint lintcatalogAny RESTRICTED/CLASSIFIED non-pin_required alias must carry require_sender_constraint, or boot refuses. Validate in staging with sandbox mode off first.
10

Deploy & roll#

The gateway is stateless at the process level; all durable state lives outside the container — the Redis-backed WORM buffer and its out-of-tamper-domain anchor. Releases roll by immutable digest with a continuous audit chain across cutover and rollback.

Deploy by image.digest, never a mutable tag — chart/ (Helm) or k8s/ (plain manifests) for a cluster, docker-compose.prod.yml for single host. The chart runs ≥2 replicas behind a PodDisruptionBudget with a default-deny NetworkPolicy and an internal-only durable Redis. An upgrade is a verified redeploy of a new digest; roll back by redeploying the previous verified digest. Because replicas share the same Redis WORM buffer and durable anchor, the audit chain stays continuous — no lost or duplicated evidence.

/healthz reports the running version — confirm it before admitting traffic. TLS is terminated upstream, so there is no in-process certificate to validate here.

Docs / Audit

Audit the ledger#

You verify that the record has not been tampered with. This is a separate capability from running the gateway — CAP_FORENSIC_READ is refused the decision feed and tenant stats, and the operator is refused forensic capture. Neither implies the other.

4 steps · attest to period evidence

01

Attest the chain#

The signed epoch attestation is the production tamper-evidence surface. /v1/audit/verify and the O(log n) inclusion proof are sandbox-only and 404 in production — deliberately, so a live gateway cannot be turned into a verification oracle.

GET /v1/audit/attestation — the latest sealed epoch header + evidence
# The blocks below read these. Set them once:
export MCPIP_URL=http://localhost:8080
export MCPIP_TOKEN=$(cat /tmp/admin.jwt)   # CAP_DIRECTORY_ADMIN — see Administer → Register skills

# NOT every block below uses that one token. The capabilities are disjoint by design —
# neither contains the other — so a forensic read with the admin token is a 403, and an
# attestation read with the forensic token is a 403. Mint the second when you reach it:
#   mcpip sandbox capabilities                       # the UUIDs, by name
#   mcpip sandbox dev-token --agent auditor \
#     --cap d5f0c9a2-4b71-4e6a-9c83-1a7f2e6b4d90     # CAP_FORENSIC_READ

curl -sS "$MCPIP_URL/v1/audit/attestation" \
  -H "Authorization: Bearer $MCPIP_TOKEN" | jq

Do not poll this. It runs a full verify_chain— re-hashing every epoch, recomputing every Merkle root, checking every Ed25519 signature. That is CPU-bound work on the event loop: four concurrent readers were measured moving authorize p50 from 8.2 ms to 260 ms. Run it on a schedule, off-peak, or against a replica.

02

Export & re-verify offline#

The authoritative record is the signed epoch chain, and you verify it without trusting the gateway — read-only, no lock, no network.

the continuous tamper check (five independent checks)
python -m mcpip_verify.cli export-audit \
  --redis-url "$MCPIP_REDIS_URL" --out audit_export.jsonl \
  --verify --pubkey worm_signing_ed25519.pub.pem --require-anchor

It runs the same five checks the gateway runs: chain linkage (monotonic epochs, contiguous seq), Merkle roots cross-checked against stored leaf hashes, epoch_hash recomputation, the Ed25519 epoch signature, and the out-of-tamper-domain anchor low-watermark that catches rollback and truncation. It fails closed on a missing key, an unparseable header, or a partially deleted epoch — and names which check failed and the first bad epoch.

03

Forensic capture#

One correlation id, one capture. Gated on CAP_FORENSIC_READ — which the operator does not hold.

GET /v1/admin/forensic/{correlation_id}
curl -sS "$MCPIP_URL/v1/admin/forensic/$CORRELATION_ID" \
  -H "Authorization: Bearer $MCPIP_TOKEN"

A 404 here is not a refusal — it means you are authorized and no capture exists for that id. An authorized-but-empty lookup stays distinguishable from a capability denial (403), which matters when the question is “was there evidence?” rather than “may I ask?”

04

Period evidence#

A control report may understate a period. It may never overstate one — so completeness is asserted by the gateway or reported as a lower bound, never assumed.

scripts/soc2_report.py — historical evidence across a window
python scripts/soc2_report.py --gateway "$MCPIP_URL" \
  --token-file operator.jwt --from-ms "$START" --to-ms "$END" > report.md

Two things the report states rather than glosses. It only claims the window was fully walked when the gateway asserted exhausted; anything else reads as a lower bound. And the decision history is a bounded scan over a trimmed buffer — if the period starts before the retention horizon, the report says partially retained and names the signed epoch chain as the record for the missing span.

Docs / PDP consumer

Use MCPIP as a decision point#

You already have a Policy Enforcement Point and need only a verdict. The AuthZEN surface executes nothing — no tool runs, no ledger allow is written on your behalf, and you keep enforcement.

2 steps · verdict and boundary

01

Ask for a verdict#

You already have a Policy Enforcement Point and you need a yes/no. This surface executes nothing — no tool runs, no ledger allow is written on your behalf.

POST /v1/authz/decision — OpenID-AuthZEN
curl -sS "$MCPIP_URL/v1/authz/decision" \
  -H "Authorization: Bearer $MCPIP_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"subject":{"type":"agent","id":"agent-eng-1"},
       "action":{"name":"invoke"},
       "resource":{"type":"skill","id":"skill_company_overview"}}'

# -> {"decision": true}

The verdict carries no reason. { decision: bool } and nothing else — thresholded as an invariant, because a PDP response that explained itself would be an oracle for probing the policy surface from outside the tenant. At 5 output tokens it is also the cheapest surface on the gateway.

02

What you still own#

The trade this surface makes, stated plainly.

ConcernWho owns itWhy
EnforcementyouMCPIP returns a verdict; nothing stops your PEP from ignoring it. If you need the audit trail to prove the call was authorized BEFORE it ran, use POST /v1/authorize instead — that is the write-before-execute path.
Identityyour IdPUnchanged: a signed JWT with all 8 required claims, EdDSA or RS256. MCPIP never mints identity and never derives it from the network.
Durabilitynot applicableThe decision endpoint is off the fsync path, so it is not bounded by the write-before-execute durability floor the agent path pays — and correspondingly writes no allow record.

Docs / Review

Review the catalog#

You approve what may enter the tenant's tool surface. CAP_CATALOG_REVIEWER is the one route the operator cannot reach — which is where 'there is no super-admin' stops being a claim and becomes a 403.

1 step · extension approval

01

Approve a catalog extension#

The reviewer is the only client type that can let a new tool surface into the tenant. It holds CAP_CATALOG_REVIEWER and nothing else.

GET /v1/admin/extensions/pending
curl -sS "$MCPIP_URL/v1/admin/extensions/pending" \
  -H "Authorization: Bearer $MCPIP_TOKEN"

This is the clearest demonstration that MCPIP has no super-admin. The operator — the most privileged human persona — is 403 on this route, and the reviewer is 403 on everything the operator holds. Neither capability subsumes the other.

That separation is the point: registering an alias (operator) and approving a new catalog surface (reviewer) are the two ways the reachable-target set can grow, and they deliberately require two different identities.

Docs / Reference

API reference#

The complete configuration and audit surface — every capability UUID, every environment variable, every WORM decision reason, and every endpoint, each linking back to the step that uses it.

Capabilities#

Privileged actions gate on capability UUIDs, never on role strings — a token claiming role: "platform-ops" authorizes nothing. The values below are the authorization surface, so they are given literally rather than described.

CapabilityHeld byGrants / refused
CAP_DIRECTORY_ADMINoperatorb8e4a1d7-2c6f-4e93-9a05-7f1c3b5d8e20 — grants: register/enable/disable skills · decision feed · tenant stats · operator minting. Refused: forensic capture · extension approval.
CAP_FORENSIC_READauditord5f0c9a2-4b71-4e6a-9c83-1a7f2e6b4d90 — grants: forensic capture for a correlation id. Refused: decision feed · tenant stats · skill registration.
CAP_CATALOG_REVIEWERcatalog reviewer7a1f9c34-2e58-4b6d-9f01-3c7a5e2b8d46 — grants: approve/reject pending catalog extensions. Refused: everything the operator holds.
CAP_COMPARTMENT_GRANTgrant issuer9c2b6f14-7a3d-4e8b-b1c0-2f5a9d3e4c71 — grants: issue a time-boxed delegated grant into a compartment. Refused: revocation (a separate capability, deliberately).
CAP_COMPARTMENT_REVOKEgrant revoker3e7d1a95-6c4b-42f0-8a9e-1b2c3d4e5f60 — grants: revoke an active delegated grant. Refused: issuing one.

The matrix is non-hierarchical in both directions. CAP_DIRECTORY_ADMIN does not subsume CAP_FORENSIC_READ or CAP_CATALOG_REVIEWER, and issuing a delegated grant is a different capability from revoking one. Compromising the operator yields neither payload forensics nor extension approval — there is no capability that implies another.

Environment variables#

The gateway is configured entirely through MCPIP_ environment variables, resolved once at boot into a validated, immutable settings object. Variables marked required are hard boot dependencies with MCPIP_SANDBOX_MODE=false (the default) — provisioning them is Administer, step 03.

VariableKindDescription
MCPIP_SANDBOX_MODEbool · falseThe posture switch. false (default) is fail-closed production; true mounts the sandbox forge — the dev-token minter and the OTP peek — behind a loud banner. Never true in production.
MCPIP_REDIS_URLrequiredurlRedis holding payload locks, grants, quarantine, and the durable WORM buffer. Must be linearizable with AOF appendfsync always and noeviction; production refuses to boot if it cannot confirm that persistence posture.
MCPIP_JWT_ISSUERstringExpected iss of minted principal tokens — must match your IdP. Default mcpip-demo-idp.
MCPIP_JWT_AUDIENCEstringExpected aud of minted tokens — your gateway’s audience. Default mcpip-gateway.
MCPIP_JWT_PUBLIC_KEY_PATHrequiredpath · PEMYour IdP’s public signing key. Identity is verify-only: the gateway checks tokens, never mints them. (Wire a JWKS provider for rotating keys.)
MCPIP_WORM_SIGNING_KEY_PATHrequiredpath · PEMEd25519 private key that signs the WORM audit epochs. Gateway-held; materialized 0600 at deploy.
MCPIP_WORM_PATHpathWORM ledger path on a durable volume (default ./mcpip_worm.jsonl; /var/lib/mcpip/mcpip_worm.jsonl in the image).
MCPIP_WORM_ANCHOR_PATHpathAppend-only head anchor (the rollback / truncation watermark). Must sit on a durable volume distinct from Redis — it is the out-of-tamper-domain witness.
MCPIP_INTEGRITY_MANIFEST_PATHrequiredpathSigned boot-integrity manifest. At startup the gateway re-hashes every shipped source file against it and refuses to boot on any mismatch. Required in production.
MCPIP_INTEGRITY_PUBLIC_KEY_PATHrequiredpath · PEMRelease-root public key that verifies the integrity manifest. Required in production.
MCPIP_LICENSE_PATHrequiredpath · JSONEd25519-signed entitlement document. Gates process boot only — never consulted by the per-request authorization pipeline. Required in production.
MCPIP_LICENSE_PUBLIC_KEY_PATHrequiredpath · PEMLicense-root public key that verifies the license signature and validity window. Required in production.
MCPIP_AUTHN_WEBHOOK_URLurlHTTPS sink the payload-bound one-time PIN is pushed to (SSRF-guarded, HMAC-SHA256-signed). Required together with the secret path for any PIN_REQUIRED skill; setting exactly one is a fail-closed boot error. AUTO-only deployments leave both blank.
MCPIP_AUTHN_WEBHOOK_SECRET_PATHpathFile holding the ≥32-byte HMAC-SHA256 signing secret for the step-up delivery webhook. Materialized 0600 at deploy.
MCPIP_AUTHN_TOTP_KEY_PATHpathMaster key that enables per-user RFC 6238 TOTP 2FA (enrollment + the TOTP-gated OTP reveal). Absent → every authenticator surface is an opaque 404.
MCPIP_API_HOSTstring · 0.0.0.0Bind address. TLS is terminated upstream (ingress / mesh / IAP) — the gateway serves plain HTTP inside the perimeter.
MCPIP_API_PORTint · 8080Listen port.
MCPIP_MAX_IN_FLIGHTint · 64Per-worker concurrency ceiling; arrivals above it shed (503) to protect tail latency. A shed request never reaches authorize().

WORM decisions#

Every protected request lands as exactly one decision in the signed Merkle-epoch WORM log (written before dispatch — see Administer, step 07). A deny additionally records a deny_reason; an admin mutation records an admin_action. None of these ever cross the agent boundary — the caller sees only the generic denial plus a correlation_id.

decision

allowdenyadmin_action

deny_reason (WORM-only)

jwt_invalidjwt_claims_missingidentity_injectionschema_violationdepth_exceededsize_exceededillegal_characterunknown_formatunknown_vendorunknown_aliascross_tenantcompartment_deniedcapability_deniedsender_constraint_requiredpin_requiredpin_not_foundpin_mismatchpayload_mismatchcanary_trippedagent_quarantinedprincipal_revokedalias_disabledotp_delivery_failedpolicy_deniedpolicy_gate_deniedrate_limitedtransport_errorlock_errorinternal

admin_action

skill_registerskill_disableskill_enableprincipal_revokeprincipal_reactivateoperator_user_inviteauthenticator_enrollauthenticator_confirmauthenticator_disableotp_revealforensic_read

API endpoints#

Every wire surface documented in the guides, in one index. Each row links to the step that covers it end to end.

POST/v1/authorize

The single authorization choke point. 200 ExecutionReceipt (executed) · 202 StagedChallenge (step-up) · 403 opaque deny. Identity via Authorization: Bearer. Authorize a tool call

MCP/v1/mcp

MCP-native JSON-RPC 2.0 (initialize, tools/list, tools/call). The gateway IS the MCP server; tools/list is pruned to the verified identity. Connect your agent

GET/v1/catalog

The opaque aliases this identity may see — metadata only (risk tier, transport class, classification), never the real target. Connect your agent

POST/v1/authz/decision

OpenID-AuthZEN PDP: subject/action/resource → { decision: bool }. Decision-only — never executes, vends, or stages. Ask for a verdict

POST/v1/dev/token

SANDBOX ONLY — mint a sandbox EdDSA JWT → { jwt }. Returns 404 in production (identity stays with your IdP). ~5-minute expiry. Get a token

GET/v1/authenticator/{challenge_id}

SANDBOX ONLY — peek the staged one-time PIN, standing in for the enrolled device. 404 in production. Clear a step-up

GET/v1/version

Running / latest release, entitlement channel, and signed provenance (notifier only — an upgrade is a redeploy). JWT-gated. Connect your agent

GET/v1/license

The boot-verified entitlement document, for operator visibility. Never consulted per request. JWT-gated. Boot the gateway

GET/healthz

Liveness plus the running version (read dynamically from the VERSION file). Boot the gateway

GET/readyz

Readiness — gated on Redis. Fails closed when the backing store is unreachable. Boot the gateway

GET/metrics

Prometheus exposition. Closed-enum labels only — no tenant, agent, alias, or deny_reason ever appears, so /metrics can’t be scraped as a deny oracle. Monitor & audit

POST/v1/admin/skills/register

Register a new opaque alias → hidden target for your tenant (service/access + risk tier + classification). Additive-only, cloud_rest only. CAP_DIRECTORY_ADMIN. Register skills

POST/v1/admin/users/invite

Invite an operator by email; the role label authorizes nothing. Returns the record + a one-time invite reference. CAP_DIRECTORY_ADMIN. Operators & 2FA

GET/v1/admin/users

The console team roster. role is a closed enum (admin|member|viewer) and authorizes nothing; status is invited|active|disabled. CAP_DIRECTORY_ADMIN. Operators & 2FA

POST/v1/admin/users/{email}

PUT updates role and/or status (at least one); DELETE removes the record. An unknown enum value is a 400 naming what is allowed. Prefer status=disabled over DELETE to keep the audit trail. CAP_DIRECTORY_ADMIN. Operators & 2FA

POST/v1/authenticator/enroll

Begin per-user TOTP 2FA: returns an otpauth:// URI once; confirm with /v1/authenticator/enroll/confirm. Gated by MCPIP_AUTHN_TOTP_KEY_PATH. Operators & 2FA

POST/v1/delegate

A session grants a CHILD session a strict subset of its own authority (caps ⊆, compartment same-or-narrower, expiry min-of-three, depth ≤ 4) — refused, never silently intersected. No capability needed: registration can only narrow the caller. WORM-sealed before it is live. Requires MCPIP_DELEGATION_ENABLED (404 otherwise). Delegate sessions

POST/v1/delegate/revoke

A parent revokes one of its own descendants; the whole subtree under that session dies (cascade by construction). Non-ancestors get an opaque deny — no existence oracle. Delegate sessions

GET/v1/admin/delegations

Every LIVE grant for the tenant — feeds the console Delegation lineage panel. CAP_DIRECTORY_ADMIN; 404 when delegation is disabled. Delegate sessions

GET/v1/admin/decisions/recent

The live decision feed — a tenant-scoped whitelist projection of the WORM tail (alias, decision, deny_reason, session attribution, class, correlation). CAP_DIRECTORY_ADMIN. Monitor & audit

GET/v1/admin/stats

Deployment / license / usage plus the honest dark-feature posture block. CAP_DIRECTORY_ADMIN, aggregates only. Monitor & audit

GET/v1/audit/proof/{event_id}

SANDBOX ONLY — O(log n) Merkle inclusion proof for one audited event; 404 in production (use python -m mcpip_verify.cli export-audit --verify there). Monitor & audit

GET/v1/audit/attestation

The signed WORM attestation bundle — the latest sealed epoch header + evidence. CAP_DIRECTORY_ADMIN (the bundle commits to the GLOBAL WORM head, so it is gated on the admin capability rather than the auditor read). Do not poll — it runs a full verify_chain. Attest the chain

GET/v1/audit/verify

SANDBOX ONLY — recompute the signed Merkle-epoch chain over HTTP. 404 in production; use python -m mcpip_verify.cli export-audit --verify there. Monitor & audit