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.
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.
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.
git clone https://github.com/mcpip-security/mcpip.git && cd mcpip
./scripts/quickstart.shIdempotent: 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.
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.
/v1/dev/token| Field | Type | Description |
|---|---|---|
tenant_id | string | Tenant the agent acts under. Defaults to the sandbox tenant (tenant-acme). |
agent_id | string | Agent id recorded on the minted token. Defaults to agent-orchestrator-1. |
role | string | Descriptive label ONLY — it authorizes nothing. Defaults to ops. |
compartment | uuid | null | Optional compartment UUID this principal is scoped to (team/MCP separation). |
capabilities | uuid[] | null | Optional capability UUIDs. The well-known admin/audit caps are at GET /v1/dev/capabilities. |
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.
Authorization: Bearer <jwt> # never a URL, never a query string, never a log lineIn 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.
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.
{
"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.
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.
/v1/authorize| Field | Type | Description |
|---|---|---|
source_formatrequired | enum | One 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. |
vendor | string | Alternative 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_callrequired | object | The 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). |
jwt | string | null | Optional — identity is normally taken from the Authorization: Bearer header instead. |
pin / challenge_id | string | null | The step-up completion pair (step 05). Supplied together, never one alone. |
{
"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
}{
"correlation_id": "…",
"action_required": "approve in the enrolled authenticator",
"challenge_id": "…", // the payload-bound lock id (step 05)
"risk_tier": "pin_required"
}{ "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.
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.
/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).
# 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).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 → 200The 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.
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.
| Field | Type | Description |
|---|---|---|
correlation_id | string | The one handle an agent may quote to a human operator to locate this decision in the audit log. |
decision | string | Always "allow" on a 200 (a deny never reaches this shape). |
status | string | Always "committed" — the WORM ALLOW record was written before dispatch. |
transaction_ref | string | txn_ + uuid4 — a per-execution reference. |
executed_target_class | string | cloud_rest | legacy_mainframe | cloud_iam — the coarse transport class only. Never entry.target. |
worm_sequence | int | The audit anchor the operator can quote to find the sealed decision record. |
vended_credential | object | null | Present 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.
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.
| Outcome | Class | What the caller sees — and what to do |
|---|---|---|
403 · ErrorResponse | policy | Every 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 request | schema | A 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 error | step-up | A StagedChallenge is a success path, not a failure — complete it with the payload-bound PIN (step 05). |
SDK · MCPIPDenied | client | Both 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
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.
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 bootSet 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.
# 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.
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.
# 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.
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.
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)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.
/v1/admin/skills/registerRequires 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.
| Field | Type | Description |
|---|---|---|
aliasrequired | string | The opaque agent-facing name, e.g. skill_aws_dynamodb, skill_spend_summary. |
targetrequired | string | The real, hidden target the agent never sees. cloud_rest only. |
service | string | null | Advisory 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. |
classification | string | unclassified | restricted — display/annotation only (defaults to unclassified; restricted ⇒ pin_required). The classified tier is seeded-config only, not accepted by this endpoint. |
# $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)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.
# 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 belowpython3.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 gate | Env | Behavior on failure |
|---|---|---|
Verified boot | INTEGRITY_MANIFEST_PATH + _PUBLIC_KEY_PATH | Re-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. |
License | LICENSE_PATH + _PUBLIC_KEY_PATH | Ed25519-signed entitlement, checked at boot only (never per request). Expired / tampered → refuses to start. |
WORM durability | REDIS_URL (AOF) | Refuses to boot unless Redis is appendfsync always — the write-before-execute ordering guarantee. |
Sender-constraint lint | catalog | Refuses to boot if any RESTRICTED/CLASSIFIED non-pin_required alias lacks require_sender_constraint (a bearer could otherwise read it). |
Authenticator webhook | AUTHN_WEBHOOK_URL + _SECRET_PATH | Required together for any PIN_REQUIRED skill; setting exactly one is a fail-closed boot error. AUTO-only deployments leave both unset. |
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.
/v1/admin/users/inviteInvite 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.
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…"}/v1/admin/usersThe rest of the roster lifecycle. Both fields are closed enums — an unknown value is a 400 naming what is allowed, not a silent default:
| Field | Values | Notes |
|---|---|---|
role | admin · member · viewer | A 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. |
status | invited · active · disabled | The 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 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.
/v1/authenticator/enrollPer-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.
# 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.
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.
/v1/delegate# 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.
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.
| Concern | Surface | Notes |
|---|---|---|
Liveness / readiness | GET /healthz · /readyz | /healthz reports the running version; /readyz is gated on Redis. Both fail closed. |
Metrics | GET /metrics | Decision + 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 feed | GET /v1/admin/decisions/recent | A 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-evidence | a separate capability | Verifying 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. |
Console | dashboard :5173 | The 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.
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.
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.

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.| View | Tab | What it answers — and what backs it |
|---|---|---|
| Monitor | Live | Is 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. |
| Governance | Skills | Which 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. |
| Settings | Users | Who 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. |
| Developers | Probe | Does 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. |

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.

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.
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.
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.
| Setting | Kind | Requirement |
|---|---|---|
MCPIP_SANDBOX_MODE | env | Keep 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 keys | env | MCPIP_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_URL | env | Must 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_PATH | env | Both 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 lint | catalog | Any RESTRICTED/CLASSIFIED non-pin_required alias must carry require_sender_constraint, or boot refuses. Validate in staging with sandbox mode off first. |
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
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.
# 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" | jqDo 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.
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.
python -m mcpip_verify.cli export-audit \
--redis-url "$MCPIP_REDIS_URL" --out audit_export.jsonl \
--verify --pubkey worm_signing_ed25519.pub.pem --require-anchorIt 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.
Forensic capture#
One correlation id, one capture. Gated on CAP_FORENSIC_READ — which the operator does not hold.
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?”
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.
python scripts/soc2_report.py --gateway "$MCPIP_URL" \
--token-file operator.jwt --from-ms "$START" --to-ms "$END" > report.mdTwo 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
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.
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.
What you still own#
The trade this surface makes, stated plainly.
| Concern | Who owns it | Why |
|---|---|---|
Enforcement | you | MCPIP 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. |
Identity | your IdP | Unchanged: a signed JWT with all 8 required claims, EdDSA or RS256. MCPIP never mints identity and never derives it from the network. |
Durability | not applicable | The 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
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.
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.
| Capability | Held by | Grants / refused |
|---|---|---|
CAP_DIRECTORY_ADMIN | operator | b8e4a1d7-2c6f-4e93-9a05-7f1c3b5d8e20 — grants: register/enable/disable skills · decision feed · tenant stats · operator minting. Refused: forensic capture · extension approval. |
CAP_FORENSIC_READ | auditor | d5f0c9a2-4b71-4e6a-9c83-1a7f2e6b4d90 — grants: forensic capture for a correlation id. Refused: decision feed · tenant stats · skill registration. |
CAP_CATALOG_REVIEWER | catalog reviewer | 7a1f9c34-2e58-4b6d-9f01-3c7a5e2b8d46 — grants: approve/reject pending catalog extensions. Refused: everything the operator holds. |
CAP_COMPARTMENT_GRANT | grant issuer | 9c2b6f14-7a3d-4e8b-b1c0-2f5a9d3e4c71 — grants: issue a time-boxed delegated grant into a compartment. Refused: revocation (a separate capability, deliberately). |
CAP_COMPARTMENT_REVOKE | grant revoker | 3e7d1a95-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.
| Variable | Kind | Description |
|---|---|---|
MCPIP_SANDBOX_MODE | bool · false | The 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_URLrequired | url | Redis 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_ISSUER | string | Expected iss of minted principal tokens — must match your IdP. Default mcpip-demo-idp. |
MCPIP_JWT_AUDIENCE | string | Expected aud of minted tokens — your gateway’s audience. Default mcpip-gateway. |
MCPIP_JWT_PUBLIC_KEY_PATHrequired | path · PEM | Your 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_PATHrequired | path · PEM | Ed25519 private key that signs the WORM audit epochs. Gateway-held; materialized 0600 at deploy. |
MCPIP_WORM_PATH | path | WORM ledger path on a durable volume (default ./mcpip_worm.jsonl; /var/lib/mcpip/mcpip_worm.jsonl in the image). |
MCPIP_WORM_ANCHOR_PATH | path | Append-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_PATHrequired | path | Signed 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_PATHrequired | path · PEM | Release-root public key that verifies the integrity manifest. Required in production. |
MCPIP_LICENSE_PATHrequired | path · JSON | Ed25519-signed entitlement document. Gates process boot only — never consulted by the per-request authorization pipeline. Required in production. |
MCPIP_LICENSE_PUBLIC_KEY_PATHrequired | path · PEM | License-root public key that verifies the license signature and validity window. Required in production. |
MCPIP_AUTHN_WEBHOOK_URL | url | HTTPS 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_PATH | path | File holding the ≥32-byte HMAC-SHA256 signing secret for the step-up delivery webhook. Materialized 0600 at deploy. |
MCPIP_AUTHN_TOTP_KEY_PATH | path | Master 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_HOST | string · 0.0.0.0 | Bind address. TLS is terminated upstream (ingress / mesh / IAP) — the gateway serves plain HTTP inside the perimeter. |
MCPIP_API_PORT | int · 8080 | Listen port. |
MCPIP_MAX_IN_FLIGHT | int · 64 | Per-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
deny_reason (WORM-only)
admin_action
API endpoints#
Every wire surface documented in the guides, in one index. Each row links to the step that covers it end to end.
/v1/authorizeThe single authorization choke point. 200 ExecutionReceipt (executed) · 202 StagedChallenge (step-up) · 403 opaque deny. Identity via Authorization: Bearer. → Authorize a tool call
/v1/mcpMCP-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
/v1/catalogThe opaque aliases this identity may see — metadata only (risk tier, transport class, classification), never the real target. → Connect your agent
/v1/authz/decisionOpenID-AuthZEN PDP: subject/action/resource → { decision: bool }. Decision-only — never executes, vends, or stages. → Ask for a verdict
/v1/dev/tokenSANDBOX ONLY — mint a sandbox EdDSA JWT → { jwt }. Returns 404 in production (identity stays with your IdP). ~5-minute expiry. → Get a token
/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
/v1/versionRunning / latest release, entitlement channel, and signed provenance (notifier only — an upgrade is a redeploy). JWT-gated. → Connect your agent
/v1/licenseThe boot-verified entitlement document, for operator visibility. Never consulted per request. JWT-gated. → Boot the gateway
/healthzLiveness plus the running version (read dynamically from the VERSION file). → Boot the gateway
/readyzReadiness — gated on Redis. Fails closed when the backing store is unreachable. → Boot the gateway
/metricsPrometheus 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
/v1/admin/skills/registerRegister a new opaque alias → hidden target for your tenant (service/access + risk tier + classification). Additive-only, cloud_rest only. CAP_DIRECTORY_ADMIN. → Register skills
/v1/admin/users/inviteInvite an operator by email; the role label authorizes nothing. Returns the record + a one-time invite reference. CAP_DIRECTORY_ADMIN. → Operators & 2FA
/v1/admin/usersThe console team roster. role is a closed enum (admin|member|viewer) and authorizes nothing; status is invited|active|disabled. CAP_DIRECTORY_ADMIN. → Operators & 2FA
/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
/v1/authenticator/enrollBegin per-user TOTP 2FA: returns an otpauth:// URI once; confirm with /v1/authenticator/enroll/confirm. Gated by MCPIP_AUTHN_TOTP_KEY_PATH. → Operators & 2FA
/v1/delegateA 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
/v1/delegate/revokeA 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
/v1/admin/delegationsEvery LIVE grant for the tenant — feeds the console Delegation lineage panel. CAP_DIRECTORY_ADMIN; 404 when delegation is disabled. → Delegate sessions
/v1/admin/decisions/recentThe 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
/v1/admin/statsDeployment / license / usage plus the honest dark-feature posture block. CAP_DIRECTORY_ADMIN, aggregates only. → Monitor & audit
/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
/v1/audit/attestationThe 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
/v1/audit/verifySANDBOX ONLY — recompute the signed Merkle-epoch chain over HTTP. 404 in production; use python -m mcpip_verify.cli export-audit --verify there. → Monitor & audit