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_id claim, 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

  1. Start Hangar in audit mode

    mcp-hangar --config ~/.config/mcp-hangar/config.yaml serve \
      --http --host 0.0.0.0 --port 8000

    With 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.

  2. Call refund as tenant:a and observe the audited digest

    Obtain a JWT from your IdP whose tenant_id claim is tenant:a and whose groups claim includes agents, then invoke the tool. In front-door mode external agents see the flat back-end tool name refund, not the hangar_* meta-API. The gateway answers as a Server-Sent Events stream (event: message / data: {...}), so strip the data: prefix before jq:

    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 audit the call succeeds. Because the placeholder pin almost certainly does not match the live schema, Hangar records a mismatch for review. A DigestMismatchEvent carrying tenant_id: tenant:a is emitted whenever the computed schema digest differs from the pin. With --json-logs it is logged as a domain_event line:

    {"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_digest and observed_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, with None, {}, [], 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)
    PY

    Expected output: a single 64-character lowercase hex digest.

    9f2b1c0d...a17c
  3. Pin the observed digest and switch to block

    Replace the placeholder pin with the observed digest and change enforcement from audit to block.

      tool_projection:
    -   digest_enforcement: audit
    +   digest_enforcement: block
        tenant_overrides:
          "tenant:a":
            pins:
    -         refund: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
    +         refund: 9f2b1c0d...a17c

    The value is 64 hex characters ([0-9a-f]{64}). Since 2.34.2 uppercase hex and the sha256:<hex> spelling are read as the digest they name. Anything else is logged at ERROR as invalid_config_digest_pin and holds the tool as drifted, so under digest_enforcement: block it 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 process

    Now call refund again as tenant: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 block the invocation is allowed and no mismatch event is emitted.

  4. Simulate schema drift and confirm tenant:a is blocked

    Change the refund schema 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 sends notifications/tools/list_changed, and since 2.25.0 on every tool_projection.pin_recheck_interval_s (60 seconds by default) for a pinned server that is READY. So wait out one interval -- the log shows tool_digest_pin_drift_detected -- or, before 2.25.0, restart the gateway to make it re-read. Then call refund again as tenant:a with enforcement still set to block:

    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 block Hangar refuses the invocation before it reaches the backend and emits a DigestMismatchEvent carrying tenant_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_refused with gate: digest_pin and reason: digest_mismatch.

  5. Call refund as tenant:b and confirm it is unaffected

    Obtain a JWT whose tenant_id claim is tenant:b. tenant:b has no pin for refund, so the per-tenant pin does not apply to it at all -- the same drift that blocks tenant:a is 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

KeyTypeDefaultDescription
tool_projection.digest_enforcementstringblockPer-MCP server mismatch handling: audit, warn, or block
tool_projection.tenant_overrides.<tenant>.pinsdict[str, str]{}Tool name to 64-char lowercase hex SHA-256 pin, applied only to that tenant
tool_projection.tenant_overrides.<tenant>.withdrawnlist[]Tools withdrawn for one tenant
tool_projection.withdrawnlist[]Tools withdrawn for all tenants
auth.oidc.tenant_claimstringtenant_idJWT claim mapped to CallerIdentity.tenant_id
tool_access.modestringegressTopology 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.