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 trust | Trusted internal callers | Untrusted external agents |
| Caller without a tenant identity | Allowed (server-level policy applies) | Denied (fail-closed) |
| Tool surface exposed to clients | Full hangar_* meta-API | Flat per-tenant backend tool names |
| Use case | Internal control plane / proxy | Public 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_claimdefault),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 oftool_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,ToolAccessDeniedErroron 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).
| Method | Path | Description |
|---|---|---|
POST | /api/admin/tools/{server}/{tool}/withdraw | Withdraw a tool at runtime |
POST | /api/admin/tools/{server}/{tool}/restore | Remove 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/toolsunder the/apirouter insrc/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 oftool_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_violation | Effect |
|---|---|
warn (default) | The tool is served. A warning names it, and the metric counts it. |
withdraw | The tool is withheld, exactly as an invalid annotation is. |
refuse_boot | The 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:
- active (not withdrawn) for the caller's tenant, and
- 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 fromauth.oidc.resource_uriif set, otherwise it is derived from the request.authorization_servers— every trusted issuer fromauth.oidc.issuers, or the legacy singleauth.oidc.issuerwhen 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 andWWW-Authenticatebuilders,_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.