18 -- Per-Tenant Digest Pins
Prerequisite: 16 -- Front-Door Multi-Tenant You will need: MCP Hangar 1.6.0, an OIDC issuer minting JWTs with a
tenant_idclaim, a backend whose tool schema you can pin Time: ~20 minutes Adds: Per-tenant digest pins on the call path, per-server enforcement modes (audit/warn/block) Concept: Digest pinning: tool schemas can't drift under you
The Problem
The same back-end tool can mean different things to different tenants. One
tenant has reviewed and approved an exact refund schema and wants any drift to
be rejected before the call ever reaches the backend. Another tenant has not
pinned anything and just wants the tool to keep working. A single global digest
policy cannot express both at once.
Hangar 1.4 lets you pin a tool's schema digest per tenant and enforce it on
the live invocation path. The pin only applies to callers whose JWT carries the
matching tenant_id, so one tenant gets strict integrity while another is
unaffected. Enforcement is scoped per MCP server and rolls out safely through
audit -> warn -> block, so you can observe the real digest before you turn
on blocking.
The Config
This recipe builds on the front-door setup from Recipe 16. We reuse
tool_access.mode: front_door and the OIDC tenant_claim so the front door can
deliver a tenant_id; without an authenticated tenant identity, a per-tenant
pin can never apply.
The new surface is tool_projection.tenant_overrides.<tenant>.pins: a map of
tool name to a 64-character lowercase hex SHA-256 digest. We start enforcement
in audit so the call passes while we capture the observed digest.
The role_assignments entry is what lets the tenants call anything at all: a
token Hangar accepts identifies the caller but grants no permission, and a
caller with no role gets Not authorized to invoke tool 'refund': tool:invoke permission required. Grant the role to a group your IdP puts in the token's
groups claim (the default groups_claim), or to the token's sub.
# config.yaml -- Recipe 18: Per-Tenant Digest Pins
tool_access:
mode: front_door # reuse front-door topology (Recipe 16)
auth: # validate JWTs; Hangar does not issue them
enabled: true
allow_anonymous: false
oidc:
enabled: true
issuer: https://auth.example.com
audience: mcp-hangar
resource_uri: https://hangar.example.com
tenant_claim: tenant_id # JWT claim -> CallerIdentity.tenant_id
role_assignments: # a validated JWT carries no permissions of its own
- principal: "group:agents" # matched against the token's `groups` claim
role: service-account # tool:invoke + tool:list, no hangar_* management tools
mcp_servers:
payments:
mode: remote
endpoint: http://localhost:8080/mcp
description: "Payments backend"
tool_projection:
digest_enforcement: audit # NEW: start in audit (observe, do not block)
withdrawn: [] # (optional) tools withdrawn for ALL tenants
tenant_overrides:
"tenant:a":
# NEW: pin refund for tenant:a only.
# Placeholder digest -- replace with the OBSERVED digest in Try It.
pins:
refund: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# withdrawn: [beta_tool] # (optional) per-tenant withdrawal
Save this as ~/.config/mcp-hangar/config.yaml or pass it with --config.
digest_enforcement is scoped per MCP server: setting it on payments does not
change enforcement on any other server. When you do not set it at all, the
default for that server is block. We set it explicitly to audit here only
for the observe-first rollout below.
A note on withdrawn: tool_projection.withdrawn removes a tool for every
tenant, and tenant_overrides.<tenant>.withdrawn removes it for one tenant.
Those are separate from pinning and are shown only for completeness -- this
recipe is about pins.
Try It
-
Start Hangar in audit mode
mcp-hangar --config ~/.config/mcp-hangar/config.yaml serve \ --http --host 0.0.0.0 --port 8000With
digest_enforcement: audit, a digest that does not match the pin is allowed through and recorded -- nothing is blocked yet. This is the safe window to discover the real digest. -
Call
refundastenant:aand observe the audited digestObtain a JWT from your IdP whose
tenant_idclaim istenant:aand whosegroupsclaim includesagents, then invoke the tool. In front-door mode external agents see the flat back-end tool namerefund, not thehangar_*meta-API. The gateway answers as a Server-Sent Events stream (event: message/data: {...}), so strip thedata:prefix beforejq:curl -s http://localhost:8000/mcp \ -H "Authorization: Bearer $TENANT_A_JWT" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"refund","arguments":{}},"id":1}' \ | sed -n 's/^data: //p' | jq '.result // .error'In
auditthe call succeeds. Because the placeholder pin almost certainly does not match the live schema, Hangar records a mismatch for review. ADigestMismatchEventcarryingtenant_id: tenant:ais emitted whenever the computed schema digest differs from the pin. With--json-logsit is logged as adomain_eventline:{"event_type": "DigestMismatchEvent", "mcp_server_id": "payments", "tool_name": "refund", "tenant_id": "tenant:a", "correlation_id": "...", "event": "domain_event", ...}The event object also carries
expected_digestandobserved_digest, but the log line does not print them, so the log tells you that the pin is wrong, not what to replace it with. The value to pin is the canonical digest of the tool's current schema (RFC 8785 JCS + SHA-256, withNone,{},[], and""treated as absent).mcp-hangar pin --config <file>prints it for every tool the configured servers serve (it does not read per-tenant pins, so it reports the digest without comparing it). Or hash the tool entry yourself with the same canonicalization Hangar uses:python - <<'PY' from mcp_hangar.domain.services.digest_computation import compute_tool_digest # paste the tool entry exactly as the backend advertises it refund = { "name": "refund", "description": "Refund a payment", "inputSchema": {"type": "object", "properties": {}}, } print(compute_tool_digest(refund).sha256) PYExpected output: a single 64-character lowercase hex digest.
9f2b1c0d...a17c -
Pin the observed digest and switch to
blockReplace the placeholder pin with the observed digest and change enforcement from
audittoblock.tool_projection: - digest_enforcement: audit + digest_enforcement: block tenant_overrides: "tenant:a": pins: - refund: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + refund: 9f2b1c0d...a17cThe value is 64 hex characters (
[0-9a-f]{64}). Since 2.34.2 uppercase hex and thesha256:<hex>spelling are read as the digest they name. Anything else is logged at ERROR asinvalid_config_digest_pinand holds the tool as drifted, so underdigest_enforcement: blockit is withheld until the pin is fixed; the rest of the config still loads. Before 2.34.2 such a pin was warn-skipped and the tool ran unpinned.Reload the config (hot-reload if you have it wired up, otherwise restart):
# hot-reload curl -s -X POST http://localhost:8000/api/config/reload -H "X-API-Key: <admin-key>" | jq # or just restart the serve processNow call
refundagain astenant:a:curl -s http://localhost:8000/mcp \ -H "Authorization: Bearer $TENANT_A_JWT" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"refund","arguments":{}},"id":2}' \ | sed -n 's/^data: //p' | jq '.result // .error'Expected: the call succeeds. The live schema digest matches the pin, so even under
blockthe invocation is allowed and no mismatch event is emitted. -
Simulate schema drift and confirm
tenant:ais blockedChange the
refundschema on the backend (add a required field, rename a property, change a type -- anything that alters the canonical schema). The pin is checked against the schema Hangar holds in its catalogue, which it reads when it starts the server or when the backend sendsnotifications/tools/list_changed, and since 2.25.0 on everytool_projection.pin_recheck_interval_s(60 seconds by default) for a pinned server that is READY. So wait out one interval -- the log showstool_digest_pin_drift_detected-- or, before 2.25.0, restart the gateway to make it re-read. Then callrefundagain astenant:awith enforcement still set toblock:curl -s http://localhost:8000/mcp \ -H "Authorization: Bearer $TENANT_A_JWT" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"refund","arguments":{}},"id":3}' \ | sed -n 's/^data: //p' | jq '.result // .error'Expected: the call is rejected. The new schema digest no longer matches the pin, so under
blockHangar refuses the invocation before it reaches the backend and emits aDigestMismatchEventcarryingtenant_id: tenant:a:{ "content": [{"type": "text", "text": "Tool 'refund' schema does not match its pinned digest"}], "isError": true }The refusal is logged as
batch_call_refusedwithgate: digest_pinandreason: digest_mismatch. -
Call
refundastenant:band confirm it is unaffectedObtain a JWT whose
tenant_idclaim istenant:b.tenant:bhas no pin forrefund, so the per-tenant pin does not apply to it at all -- the same drift that blockstenant:ais irrelevant here.curl -s http://localhost:8000/mcp \ -H "Authorization: Bearer $TENANT_B_JWT" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"refund","arguments":{}},"id":4}' \ | sed -n 's/^data: //p' | jq '.result // .error'Expected: the call succeeds, drift or no drift. A pin only constrains the tenant it is declared under.
What Just Happened
A per-tenant pin lives at
tool_projection.tenant_overrides.<tenant>.pins.<tool> and is just the expected
canonical digest of that tool's schema. On the live call path Hangar resolves
the caller's tenant_id (from the JWT tenant_claim, default tenant_id),
looks up a pin for the requested tool under that tenant, computes the current
schema digest, and compares. The pin is consulted only when the caller has a
matching tenant_id; an anonymous caller, or a tenant with no pin entry, falls
through to the normal projection and withdrawal logic. That is why tenant:b
sailed through the same drift that stopped tenant:a.
The digest itself is computed from the canonical tool schema: RFC 8785 JSON
Canonicalization Scheme, then SHA-256. The empty optional values None, {},
[], and "" are treated as absent, so a backend that sends an empty
annotations: {} and one that omits it produce the same digest. The pin you
store is exactly the value that canonical computation should yield for the
approved schema. A pin that is not a 64-character hex string (uppercase and
sha256:<hex> are accepted since 2.34.2) is logged as
invalid_config_digest_pin and holds its tool as drifted -- the rest of the
config still loads, and under block that one tool is withheld until the pin is
fixed. A pins or tenant_overrides block that is not a mapping refuses the
configuration, at boot, on reload and in mcp-hangar config check. Before
2.34.2 both were skipped and the tool ran unpinned.
digest_enforcement decides what happens on a mismatch, and it is scoped per
MCP server:
audit-- allow the call and record the event for audit.warn-- allow the call and emit a warning.block-- reject the call when the schema does not match the pin.
When unset, a server defaults to block. Because it is per server, turning on
block for payments does not change enforcement for any other backend. Every
mismatch -- in any mode -- emits a DigestMismatchEvent that includes the
tenant_id, which is what made the audit-first rollout possible: we ran in
audit to see the mismatch without refusing anyone, computed the real digest,
pinned it, and only then switched to block.
The audit -> block sequence is the whole point. Pinning straight to block
against a digest you have not verified will reject the first legitimate call.
Observing in audit, pinning the digest you computed, then flipping to block
gives you strict per-tenant integrity with zero guesswork.
Key Config Reference
| Key | Type | Default | Description |
|---|---|---|---|
tool_projection.digest_enforcement | string | block | Per-MCP server mismatch handling: audit, warn, or block |
tool_projection.tenant_overrides.<tenant>.pins | dict[str, str] | {} | Tool name to 64-char lowercase hex SHA-256 pin, applied only to that tenant |
tool_projection.tenant_overrides.<tenant>.withdrawn | list | [] | Tools withdrawn for one tenant |
tool_projection.withdrawn | list | [] | Tools withdrawn for all tenants |
auth.oidc.tenant_claim | string | tenant_id | JWT claim mapped to CallerIdentity.tenant_id |
tool_access.mode | string | egress | Topology mode; use front_door to face untrusted tenants |
Per-tenant pins are independent per MCP server, and a pin applies only when the
caller identity carries the matching tenant_id.
What's Next
For the full background on digest pinning -- unknown-tool policy
(allow_unverified, with allow_degraded still accepted as a deprecated alias
in 1.6.0), mismatch enforcement levels, and the per-tenant pin schema -- see the
Digest Pinning section of the
Configuration Reference.
If you are arriving here from an older release, recompute existing pins under the RFC 8785 JCS algorithm first: see 14 -- Upgrade: Digest Pinning.
Before treating a passing pin as evidence, read what a verdict establishes: the digest covers four fields, and an empty-valued one is dropped before it is computed.