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, beside the hangar_* tools the caller's role permits
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 back-end 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 back-end tool — not even through a group path. The hangar_* management tools its role permits are still listed; they are authorized by role, not by tenant.

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
    roles: tuple[str, ...] = ()

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 endpoints withdraw and restore a tool at runtime. Both require mcp_servers:lifecycle, which the built-in admin and developer roles hold, 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, which requires the admin:* grant from core 2.29.0: a global developer gets 403 without a tenant_id. Providing one scopes the action to that tenant. A caller whose grant is tenant-scoped acts on its own tenant only: omitting tenant_id means that tenant, and naming another is a 403. The body may also carry kind -- tool (the default), prompt or resource.

# 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", "kind": "tool", "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.

How a selector matches. The header name is matched case-insensitively, as HTTP header names are: Mcp-Param-Region and mcp-param-region are the same selector. The value is matched case-sensitively, with shell-style globs: a selector allowing eu does not match a request whose header (and body) say EU. List every spelling you mean to accept, or a glob such as [eE][uU].

Refusing the call is a separate opt-in.

headers:
  param_validation:
    required: true      # default: false

On, a tools/call on a modern protocol revision carrying an Mcp-Param-* header that did not reach the selector as checked -- a skipped validation after a failed listing, a header the tool does not declare, a malformed sentinel, or any Mcp-Param-* header on hangar_call, which declares none -- 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.

A handshake-era request (protocol 2025-11-25 and earlier) is not refused: it is served, and its Mcp-Param-* headers are ignored, so they decide no verdict.

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.

Mcp-Method / Mcp-Name on a handshake-era request

serve --http, in either mode, routes a handshake-era (2025-06-18 and older) POST /mcp on its SEP-2243 Mcp-Method and Mcp-Name headers when it carries them, and refuses a header that contradicts the body. Since 2.34.4:

  • such a request is answered in full. Before, the SSE answer stopped before writing anything: the client got 200 and an empty stream;
  • a repeated Mcp-Method or Mcp-Name header is refused with 400 and -32020, the answer a 2026-07-28 request already gets;
  • a client that disconnects in the middle of sending such a request no longer stops the gateway, which held its event loop and answered nothing else. The request is dropped without an answer.

A 2026-07-28 request is routed by the SDK's own entry and is not affected.

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 see the flat back-end tool names (for example read_item) and call them directly. The hangar_* tools stay listed beside them, filtered to what the caller's role may call: a viewer sees fewer than a developer, and a developer fewer than an admin.

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.

A flat tools/call checks the caller's tool:invoke permission, as hangar_call does. A caller without it -- one holding only viewer, for example -- still sees the flat names its tenant may use, and calling one is a tool error reading Not authorized to invoke tool '<tool>': tool:invoke permission required.

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.

Servers that leave and come back. Since 2.34.4 a server that leaves the fleet -- removed by a config reload, by DELETE /api/mcp_servers/{id}, or by a discovery source -- leaves tools/list too, and listing clients get notifications/tools/list_changed. A server registered again under the same name is listed again at once, with its last known tools and under the withdrawals and pins its name had, whether or not it is started on that replica; the first call starts it. When one of two servers that collided on a tool name is removed, the name is listed again and routes to the server that stayed.

Hot-loaded servers are not served here. The front door lists and routes only the servers the configuration declares. A server loaded with hangar_load is not listed, and a flat call of one of its tools answers -32601. Since 2.34.4 the hangar_load answer carries a warning saying so; to serve the server on a front door, declare it in config.yaml. See hangar_load.

Boot warm-up

At boot a front door starts its configured servers one at a time, to learn their tools: the warm-up. A first tools/list, flat tools/call or server/discover that would answer an empty catalogue because the warm-up has not projected anything yet waits for it, for at most 5 seconds. A caller with no identity, a catalogue that policy leaves empty for the caller, and an egress gateway are answered at once. Since 2.34.4:

  • GET and POST /server/discover wait on the same condition as tools/list. Before, a discover that arrived after /health/live but before the warm-up had projected the upstreams returned an empty tools list;
  • after any wait, including one that reached the deadline, the listing is read again and serves what exists by then, as server/discover already did;
  • the warm-up counts as running from before the gateway accepts a request, so a listing that arrives in the first moment waits instead of answering only the hangar_* tools;
  • the warming state ends at a bound derived from the servers being warmed: each one's start budget (one attempt at each of its two start calls, at the stdio client's 15 s or a remote server's http.connect_timeout plus http.read_timeout, never less than 30 s), summed, plus 30 s. When it passes, listings stop waiting and a front_door_warmup_bound_passed WARNING names the servers still warming. A server the warm-up starts after the bound is still projected and announced with notifications/tools/list_changed;
  • a warm-up that hangs on one server no longer stops tool_access.required_catalogue from retrying the required servers that failed: once the bound passes, the retry starts on a thread of its own.

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.