Front-Door Mode & Per-Tenant Tool Governance

New to this? The request path is the concept behind this page.

Hangar 1.3 introduces a topology mode that controls how Hangar treats the callers in front of it. The default mode, egress, assumes Hangar sits behind trusted internal callers and proxies them out to back-end MCP servers. The new front_door mode is the inverse: Hangar faces untrusted, external agents and applies fail-closed, per-tenant tool governance on every call.

This guide covers what front-door mode is, how it differs from egress, how per-tenant tool policy and tool withdrawal work, and how Hangar advertises itself as an OAuth 2.0 protected resource (RFC 9728).

Egress vs. Front-Door

The topology mode is set once at the top level of your config under tool_access.mode:

tool_access:
  mode: front_door   # "egress" (default) | "front_door"
egress (default)front_door
Caller trustTrusted internal callersUntrusted external agents
Caller without a tenant identityAllowed (server-level policy applies)Denied (fail-closed)
Tool surface exposed to clientsFull hangar_* meta-APIFlat per-tenant backend tool names
Use caseInternal control plane / proxyPublic or multi-tenant front door

If tool_access.mode is absent, Hangar uses egress, so an upgrade never switches an existing deployment to the stricter mode. A value that is present but unrecognized — front-door, frontdoor — refuses to start. Resolving that typo to egress would give a deployment that asked for the front door the permissive mode instead. This changed in 2.2.0; see the upgrade note.

Source: src/mcp_hangar/server/config.py (_init_topology_mode_from_config), src/mcp_hangar/domain/services/tool_access_resolver.py (TopologyMode).

Fail-Closed Default

The defining behavior of front-door mode is that a caller with no tenant identity is denied every tool, regardless of target. This check fires before any server-, group-, or member-level policy is evaluated, so an unauthenticated external caller can never reach a tool — not even through a group path.

Concretely, when the resolver is in front_door mode and the caller has no member/tenant (member_id is None), it returns a deny-all policy (deny_list=("*",)). In egress mode the same caller would fall through to the server-level policy.

Source: src/mcp_hangar/domain/services/tool_access_resolver.py (deny-all sentinel _DENY_ALL_POLICY, the front-door guard in the policy resolution path).

Per-Tenant Identity

The tenant of a request is carried on the caller identity. The CallerIdentity value object has a tenant_id field, which is populated from a JWT claim when OIDC authentication is enabled.

@dataclass(frozen=True)
class CallerIdentity:
    user_id: str | None
    agent_id: str | None
    session_id: str | None
    principal_type: PrincipalType = "anonymous"
    tenant_id: str | None = None

The JWT claim that maps to tenant_id is configurable and defaults to tenant_id:

auth:
  oidc:
    enabled: true
    issuer: https://auth.company.com
    audience: mcp-hangar
    tenant_claim: tenant_id   # default; the JWT claim read into CallerIdentity.tenant_id

When an authenticated request comes in over HTTP, Hangar bridges the principal into the request-scoped identity context so the per-tenant enforcement and the flat tool projection can read caller.tenant_id.

Source: src/mcp_hangar/domain/value_objects/identity.py (CallerIdentity), src/mcp_hangar/auth/config.py (tenant_claim default), src/mcp_hangar/fastmcp_server/asgi.py (identity bridge on the request path).

Per-Tenant Tool Access Policy

On top of the existing server-level allow/deny lists, 1.3 adds member-scope (per-tenant) tool access policies. These are declared per MCP server under tool_access.member, keyed by tenant ID:

mcp_servers:
  payments:
    mode: remote
    endpoint: http://payments:8080/mcp
    tool_access:
      member:
        "tenant:a":
          deny_list: [refund]          # tenant:a cannot call "refund"
        "tenant:b":
          allow_list: [charge, refund] # tenant:b is restricted to these two

The resolver merges policies as server → member: the server-level policy is combined with the matching per-tenant policy on the live call path. The effective decision is enforced when a tool is invoked, not only at list time.

Source: src/mcp_hangar/server/config.py (parsing of tool_access.member, set_standalone_member_policy), src/mcp_hangar/domain/services/tool_access_resolver.py (is_tool_allowed, server→member merge), src/mcp_hangar/server/tools/batch/executor.py (live call-path check, ToolAccessDeniedError on deny).

Tool Withdrawal

Hangar 1.3 can withdraw individual tools — make them disappear from the projection that callers see and refuse to route them — either at runtime or via config. Withdrawals are tracked by the ToolProjectionRegistry read-model, which is the single source of truth for whether a tool is active for a given tenant.

The effective withdrawal of a tool is config OR runtime: a config-declared withdrawal and a runtime withdrawal are independent overlays, and a tool is withdrawn if either says so.

Runtime withdrawal (REST API)

Two admin endpoints withdraw and restore a tool at runtime. Both require the admin permission (the lifecycle action on the mcp_servers resource) and publish a domain event (ToolWithdrawn / ToolRestored).

MethodPathDescription
POST/api/admin/tools/{server}/{tool}/withdrawWithdraw a tool at runtime
POST/api/admin/tools/{server}/{tool}/restoreRemove a runtime withdrawal

Both accept an optional JSON body with a tenant_id. Omitting it (or sending null) withdraws/restores globally for all tenants; providing one scopes the action to that tenant.

# Withdraw "refund" from the "payments" server for one tenant
curl -X POST http://localhost:8000/api/admin/tools/payments/refund/withdraw \
  -H "X-API-Key: <admin-key>" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "tenant:a"}'
{"withdrawn": true, "mcp_server": "payments", "tool": "refund", "tenant_id": "tenant:a"}

restore affects only the runtime overlay. A config-declared withdrawal persists independently.

Source: src/mcp_hangar/server/api/admin_tools.py, mounted at /admin/tools under the /api router in src/mcp_hangar/server/api/router.py.

Config-declared withdrawal

Withdrawals can also be declared in config under each MCP server's tool_projection block. These are applied as a config overlay on the ToolProjectionRegistry, so the named tools resolve as withdrawn even before they are discovered from the back end, and they survive reloads.

mcp_servers:
  payments:
    mode: remote
    endpoint: http://payments:8080/mcp
    tool_projection:
      withdrawn: [legacy_charge]          # withdrawn for ALL tenants
      tenant_overrides:
        "tenant:a":
          withdrawn: [refund]             # withdrawn for tenant:a only

Source: src/mcp_hangar/server/config.py (parsing of tool_projection, set_config_withdrawal), src/mcp_hangar/application/read_models/tool_projection.py (ToolProjectionRegistry).

Header Exposure (x-mcp-header)

SEP-2243 lets a tool annotate an inputSchema property with x-mcp-header. A conforming client then sends that argument's value as an HTTP header rather than in the body, so an intermediary can route on it without parsing the request. Two controls sit on that, one unconditional and one you configure.

Invalid annotations withdraw the tool (no configuration)

The spec makes it a client-side MUST: a client drops any tool whose x-mcp-header annotations are invalid — the annotation must sit on a property reachable through a pure properties chain, name an RFC 9110 token, be on an integer/string/boolean property, and be unique across the schema.

Hangar forwards an upstream definition verbatim, so before v2.14.0 it advertised such a tool and counted it as surface delivered while every conforming client silently dropped it. Since v2.14.0 the tool is withheld at the projection: absent from tools/list, and -32601 on the call. Shown and callable stay the same set.

A log line names the tool and the reason, and the withdrawal is counted by mcp_hangar_projection_withdrawals_total{reason="invalid_x_mcp_header"}.

header_exposure: what an upstream may oblige a client to expose

The spec's only defence against annotating a secret is a SHOULD NOT. An upstream that annotates api_key obliges every conforming client to put the key in an HTTP header, where every intermediary on the path can read it — and no client-side rule stops it. header_exposure is the enforcement point that SHOULD is missing.

mcp_servers:
  payments:
    mode: remote
    endpoint: https://payments.example.com/mcp
    header_exposure:
      deny_annotated: ["*token*", "*secret*", "*password*", "api_key", "*_key"]
      on_violation: withdraw          # warn (default) | withdraw | refuse_boot

deny_annotated globs are matched case-insensitively against two things: the annotation token and the property path. An upstream can name the property api_key and send it as X-Key, or name it credential and send it as X-Auth-Token; either spelling is the same exposure, so either matches.

on_violationEffect
warn (default)The tool is served. A warning names it, and the metric counts it.
withdrawThe tool is withheld, exactly as an invalid annotation is.
refuse_bootThe catalogue is not served at all; tools/list fails.

An unknown on_violation is refused at parse, not defaulted — a typo that silently resolved to warn would report the control as enforcing while the action you asked for never happened.

Withdrawals land on mcp_hangar_projection_withdrawals_total{reason="header_exposure_<action>"}.

Three things worth being precise about

refuse_boot refuses to serve the catalogue, not to start the process. The violation is only knowable after an upstream's tools have been discovered, so there is no earlier point at which it exists. Choose it when a smaller catalogue would be a worse outcome than an unavailable one.

It governs what leaves in a header, not what a tool accepts. An unannotated api_key parameter is not an exposure and is not denied. The control is about the SEP-2243 header path; use the tool access policy to govern which tools a tenant may call at all.

The schema is never edited. Stripping the offending annotation would change the inputSchema and therefore the JCS digest over {name, description, inputSchema, outputSchema} — it would move every pin and read as upstream drift. A warned tool is served byte-identical to what the upstream returned.

A member of a group inherits the block its group declares, so a header_exposure written once on the group covers every member. Deleting the block and reloading restores the tools it withheld.

Source: src/mcp_hangar/domain/policies/header_exposure.py, src/mcp_hangar/fastmcp_server/flat_tool_projection.py (_invalid_header_annotation, _denied_header_exposure).

Mcp-Param-*: a header nobody validated decides nothing

The two controls above govern what an upstream may ask a client to put in a header. This one governs what Hangar does with the value that comes back.

SEP-2243's safety property is header-body agreement: the header carries the same value the call will execute with, so nobody can route on one value and execute another. The mcp SDK enforces it before dispatch — and does so fail-open by design. Resolving the called tool's schema means an internal tools/list; when that listing fails, validation is skipped and the call is dispatched anyway, because header validation must never break a working call path.

That is an SDK decision Hangar inherits. Since v2.14.0 it is also a Hangar decision, because an MCPEgressPolicy can select on Mcp-Param-*. We are the intermediary the SEP is talking about.

A skipped validation is a non-match (default, no configuration). A request whose Mcp-Param-* headers nothing checked satisfies no allow, deny or requireApproval header selector. It falls through to the tool rules and the policy default — the same treatment a handshake-era request already gets. It is deliberately not an implicit deny: an unearned deny is the same defect as an unearned allow with the sign flipped, and one caller's unvalidated header must not decide another caller's request. The verdict says which happened, so "no rule matched" and "the rules were not consulted" are distinguishable in an audit record.

A deployment that writes no headers.* selectors cannot observe any of this.

Refusing the call is a separate opt-in.

headers:
  param_validation:
    required: true      # default: false

On, a tools/call whose Mcp-Param-* headers could not be validated is answered with HEADER_MISMATCH (-32020) and the message "the request's Mcp-Param-* headers could not be validated against its body", instead of being served. The code is a slight overstatement — we know nobody could check, not that the header disagrees — and it is still preferable to a Hangar-specific third code for one client-visible class.

The block is global, not per-server: the condition it reacts to is a failed listing on this request, not a property of the upstream the call would reach. A non-boolean refuses to start, as an unrecognised tool_access.mode does.

Turning it on converts an upstream availability problem into a client-visible refusal for every call carrying header parameters, whether or not any policy selects on them. That is the trade, and it is why the control is opt-in while the non-match above is not.

Skips are counted by mcp_hangar_param_header_validation_skipped_total{reason} — listing_failed is the one that reaches either control; tool_not_listed is already a -32601, legacy_protocol is an era rather than a failure, and invalid_annotation is held shut by the withdrawal described above.

Decision: ADR-025. Source: src/mcp_hangar/domain/policies/egress_l7.py (evaluate_headers), src/mcp_hangar/context.py (bind_routing_headers), src/mcp_hangar/fastmcp_server/flat_tool_projection.py.

Flat Per-Tenant Tool Re-Export

In egress mode, clients see Hangar's hangar_* meta-API (hangar_list, hangar_status, etc.) and call back-end tools through it. In front_door mode, external agents instead see only the flat back-end tool names (for example read_item) — the clean tool surface they expect, with the meta-API hidden.

This is done by re-registering the low-level tools/list and tools/call handlers when the topology mode is front_door. Each request builds a per-tenant flat_name → (mcp_server, tool) map, filtered to tools that are:

  1. active (not withdrawn) for the caller's tenant, and
  2. allowed for that tenant by the member-scope policy.

If two different back-end servers expose the same flat tool name, both are dropped and a flat_tool_name_collision warning is logged — Hangar will not route an ambiguous name to the wrong back end. Single-backend deployments never hit this path. In egress mode the handlers are not replaced and the full hangar_* surface is intact.

Source: src/mcp_hangar/fastmcp_server/flat_tool_projection.py.

OAuth Protected Resource Discovery (RFC 9728)

Hangar acts as an OAuth 2.0 resource server. It validates bearer tokens (JWT/OIDC) but it does not issue them, perform dynamic client registration, or run any authorization-server logic. To let clients discover where to obtain a token, Hangar implements RFC 9728 Protected Resource Metadata.

Metadata endpoint

When an OIDC issuer is configured, Hangar serves the metadata document at:

GET /.well-known/oauth-protected-resource

The response advertises the resource server and its trusted authorization servers:

{
  "resource": "https://hangar.example.com",
  "authorization_servers": [
    "https://issuer-a.example.com",
    "https://issuer-b.example.com"
  ]
}
  • resource — the public URI identifying this resource server. It comes from auth.oidc.resource_uri if set, otherwise it is derived from the request.
  • authorization_servers — every trusted issuer from auth.oidc.issuers, or the legacy single auth.oidc.issuer when no issuer list is configured.

When auth.oidc.resource_uri is set, Hangar also uses that value as the required JWT aud claim. This binds accepted tokens to the advertised resource URI (RFC 8707). Without resource_uri, validation falls back to each issuer's configured audience.

This endpoint is unauthenticated (discovery must work without a token). If no OIDC issuer is configured, it returns 404.

401 challenge

When OIDC is active and a request fails authentication, the 401 response carries a WWW-Authenticate header that points clients at the metadata URL:

WWW-Authenticate: Bearer resource_metadata="https://hangar.example.com/.well-known/oauth-protected-resource", ApiKey

When OIDC is not configured, the challenge is simply Bearer, ApiKey.

auth:
  enabled: true
  allow_anonymous: false
  oidc:
    enabled: true
    resource_uri: https://hangar.example.com   # advertised as "resource" in PRM
    tenant_claim: tenant_id
    issuers:
      - issuer: https://issuer-a.example.com
        audience: https://hangar.example.com
        jwks_uri: https://issuer-a.example.com/jwks
      - issuer: https://issuer-b.example.com
        audience: https://hangar.example.com
        jwks_uri: https://issuer-b.example.com/jwks

Source: src/mcp_hangar/auth/prm.py (PRM body and WWW-Authenticate builders, _PRM_PATH), src/mcp_hangar/server/lifecycle.py (PRM route registration), src/mcp_hangar/fastmcp_server/asgi.py (401 challenge), src/mcp_hangar/auth/config.py (resource_uri).

Full Config Example

# config.yaml — front-door deployment

# Topology: face untrusted external agents, fail-closed per tenant.
tool_access:
  mode: front_door

# Refuse a call whose Mcp-Param-* headers could not be validated against its
# body, rather than serving it (ADR-025). Optional; a header selector already
# refuses to match such a request without this.
headers:
  param_validation:
    required: true

# Validate JWTs from your IdP. Hangar is a resource server, not an issuer.
auth:
  enabled: true
  allow_anonymous: false
  oidc:
    enabled: true
    resource_uri: https://hangar.example.com
    tenant_claim: tenant_id
    issuers:
      - issuer: https://issuer-a.example.com
        audience: https://hangar.example.com
        jwks_uri: https://issuer-a.example.com/jwks
      - issuer: https://issuer-b.example.com
        audience: https://hangar.example.com
        jwks_uri: https://issuer-b.example.com/jwks

mcp_servers:
  payments:
    mode: remote
    endpoint: http://payments:8080/mcp
    description: "Payments backend"

    # Per-tenant tool access policy (server → member merge).
    tool_access:
      member:
        "tenant:a":
          deny_list: [refund]
        "tenant:b":
          allow_list: [charge, refund]

    # Config-declared tool withdrawals (effective = config OR runtime).
    tool_projection:
      withdrawn: [legacy_charge]
      tenant_overrides:
        "tenant:a":
          withdrawn: [refund]

REST Endpoints

  • GET /.well-known/oauth-protected-resource — RFC 9728 metadata.
  • POST /api/admin/tools/{server}/{tool}/withdraw — runtime withdraw.
  • POST /api/admin/tools/{server}/{tool}/restore — runtime restore.

For the full admin and auth surface, see REST API and Authentication & Authorization.

What's Next

Walk through a runnable end-to-end setup in the cookbook recipe 16 — Front-Door Multi-Tenant.

Before treating any of this mode's answers as evidence, read what a verdict establishes: an empty projection, a -32601 and a header-selector verdict each prove less than they look like they do.