Configuration Reference

All MCP Hangar behavior is controlled through a YAML configuration file and environment variables. Every command finds the config file by one rule, highest first: a path on the command line (the command's own flag, then the global --config), then $MCP_CONFIG, then ./config.yaml if the working directory has one, then ~/.config/mcp-hangar/config.yaml, the file init writes -- see CLI: which file a command reads. Where a setting has both an environment variable and a YAML key, which one wins depends on the setting: most environment variables win, but the top-level rate_limit block wins over MCP_RATE_LIMIT_*, and logging.level wins over an MCP_LOG_LEVEL of INFO. Each section below says which applies.

mcp_servers

MCP Server definitions. Each key is a unique MCP server ID.

mcp_servers:
  math:
    mode: subprocess
    command: [python, -m, math_server]
    idle_ttl_s: 300
    health_check_interval_s: 60

  my-api:
    mode: remote
    endpoint: https://api.example.com/mcp
    idle_ttl_s: 600

  my-container:
    mode: docker
    image: my-mcp:latest
    volumes:
      - ./data:/data:ro
    resources:
      memory: "512m"
      cpu: "1.0"
KeyTypeDefaultRangeDescription
modestr"subprocess"subprocess, docker, container, remote, groupMCP Server mode. container is an alias of docker. podman is not a mode and refuses the config; select Podman with MCP_CONTAINER_RUNTIME. group is described under groups.
commandlist[str]----Command for subprocess mode (required for subprocess)
imagestr----Docker image for docker mode (required for docker)
endpointstr----HTTP endpoint for remote mode (required for remote)
envdict[str, str]{}--Environment variables passed to the MCP server process
idle_ttl_sint3001--86400Seconds of inactivity before the MCP server is auto-stopped
health_check_interval_sint605--3600Interval between health checks in seconds
max_consecutive_failuresint31--100Consecutive health check failures before marking degraded
volumeslist[str][]--Docker volume mounts (docker mode only)
builddict----Docker build configuration (docker mode only)
resourcesdict{memory: "512m", cpu: "1.0"}--Container resource limits (docker mode only)
network / network_modestr"none"--Container network mode (docker mode only)
read_onlybooltrue--Read-only filesystem (docker mode only)
userstr----Container user. "current" maps to host uid:gid
argslist[str]----Container CMD override (docker mode only). A subprocess server does not read it; put its arguments in command
descriptionstr----Human-readable MCP server description
toolslist or dict----Predefined tool schemas (list) or access policy (dict). See below.
authdict----HTTP auth configuration (remote mode only)
tlsdict----TLS for a remote upstream: verify_ssl (bool, default true) and ca_cert_path (a bundle to trust, for an upstream signed by your own CA). Both are honoured from 2.5.0; earlier releases accepted and discarded them
httpdict----HTTP transport configuration (remote mode only)
max_concurrencyint-->= 0Per-MCP server concurrency limit; 0 means no limit. Unset uses execution.default_mcp_server_concurrency
max_response_bytesint--> 0Largest upstream response this server's transport reads before failing the call with ResponseTooLarge. Wins over execution.max_response_bytes and MCP_MAX_RESPONSE_BYTES. Anything but a positive integer refuses the config
capabilitiesdict----Declared capability contract (network, filesystem, environment, tools, resources, enforcement_mode). Hangar enforces these at runtime and flags deviations.
accessdict----Prompt and resource access policy. See Prompts and resources
tool_accessdict----tool_access.member.<tenant>: a per-tenant policy for this server, with the same keys as tools plus access
tool_projectiondict----Digest pins. See Digest Pinning
header_exposuredict----See Header Exposure

network_mode and user are read, but the 2.24.0 key check does not list them: each logs unknown_config_key, and HANGAR_CONFIG_STRICT=1 and mcp-hangar config check refuse a file that sets one. Write network rather than network_mode.

mode: remote and the SSRF policy

A remote upstream declared here is not covered by the SSRF policy, and the difference from registering one through the REST API is deliberate.

POST /api/mcp_servers validates the endpoint before accepting it -- an address in a private range, or a cloud metadata host, is answered 400 ssrf_blocked -- and marks the upstream so that every later connection re-resolves the hostname and refuses an address the policy rejects. That second check is what closes DNS rebinding: a name that resolved to a public address when it was registered, and is later re-pointed at an internal one, is refused on the next connection rather than followed.

An upstream declared in config.yaml gets neither check. The file is trusted input written by the operator, and a gateway sitting in the same cluster as its backends usually means the private address it wrote there; refusing it would break working deployments to enforce a rule about a channel the operator already controls. From 2.6.0 the gateway logs one line per such upstream at startup (ssrf_policy_not_applied_to_config_file_endpoint), naming the server, its endpoint and what does not apply to it. Nothing is refused.

If you want an endpoint checked, register it through the REST API instead of the file. The reasoning is recorded in ADR-021.

From 2.27.0, ssrf.trusted_networks lets the API register an endpoint in a private network the deployment names, such as a Kubernetes Service range.

tools dual format

The tools key accepts two formats depending on intent.

List format -- predefined tool schemas. The MCP server is not started to discover tools; schemas are served directly.

mcp_servers:
  static-math:
    mode: subprocess
    command: [python, -m, math_server]
    tools:
      - name: add
        description: Add two numbers
        inputSchema:
          type: object
          properties:
            a: { type: number }
            b: { type: number }

Dict format -- tool access policy using fnmatch glob patterns, matched against the bare tool name as the upstream reports it.

mcp_servers:
  restricted:
    mode: subprocess
    command: [python, -m, full_server]
    tools:
      allow_list:
        - "safe_*"
        - "read_*"
      deny_list:
        - "internal_*"

When allow_list is set, only matching tools are exposed. When only deny_list is set, all tools except matches are exposed.

KeyTypeDefaultDescription
allow_listlist[str][]Glob patterns; when set, only matching tools are exposed and deny_list is ignored
deny_listlist[str][]Glob patterns; matching tools are hidden. Wins over approval_list
approval_listlist[str][]Glob patterns; matching tools stay visible but each call is held for a human decision before it runs
approval_timeout_secondsint300How long a held call waits for a decision. Must be a positive integer
approval_channelstr(unset)Which delivery channel notifies for approvals this policy holds. Unset means the deployment's approvals.channel. Approvals are routed on it

The same block is accepted at every scope that takes an access policy — an mcp_servers entry, a groups entry, a group member, and the per-tenant tool_access.member block — and all four go through one parser, so a key cannot be honoured at one scope and dropped at another.

An invalid policy refuses the configuration (2.25.0). A tools: policy at any of those scopes, or an access: block, with one invalid field fails the boot, and a reload is refused with the previous policy kept in force. The ConfigurationError names the scope and the field:

Invalid tools access policy for mcp_server 'calc': Invalid deny_list: expected a list of patterns, got str 'add'

Invalid means an approval_timeout_seconds that is not a positive integer, an empty or non-string pattern, a whitespace-only approval_channel, or an allow_list, deny_list or approval_list that is not a list, or a policy block that is present but is not a mapping -- tools: add, a null tools:, or the same at access:, tool_access: or a tool_access.member entry (on a server, tools: may still be a list of tool schemas, each a mapping). Write a single pattern as deny_list: [add]. Before 2.25.0 an invalid field logged invalid_tools_access_config (or its group, member, tenant or access variant) and the gateway booted with no policy for that scope, so denied tools ran and approval-listed tools ran without a hold; and a string list was split into its characters, so deny_list: add denied a and d and allowed add (mcp-hangar#1648, mcp-hangar#1718). A policy you mean to have no effect is removed by deleting the block.

Prompts and resources (access)

tools: governs tools. The sibling access: block governs the other two kinds with the same parser, the same value object and the same resolver — only the kind the policy is keyed under differs:

mcp_servers:
  docs_server:
    mode: remote
    endpoint: https://docs.example.com/mcp
    access:
      prompt:
        deny_list: ["draft_*"]
      resource:
        allow_list: ["docs://*"]

A resource is matched by its upstream URI (docs://guide/1), not the hangar://<upstream>/… form the gateway projects — the upstream form is the stable identity, and the owning server is already the policy scope.

Enforcement lands at both ends of every surface: prompts/list + prompts/get, resources/list + resources/templates/list + resources/read, and the handed-out resource_link catalogue. A denied item is absent from the listing and refused on fetch, with the refusal indistinguishable from the one a nonexistent item gets. An undefined block leaves that kind unrestricted, the same rule an undefined tools: block has always followed.

approval_list is not accepted here, and a config carrying it is refused at load. The approval gate runs on tool calls and nowhere else; a hold on a fetch is decided against in ADR-024. Use deny_list to withhold a prompt or a resource. Before 2.13.1 the key parsed, registered, and enforced nothing while the startup check refused the boot over it (#1042).

Holding a tool for a human (approval_list)

approval_list marks tools as visible but gated: the caller's tools/call is held, an approval record is created and notified through this policy's channel, and the call runs only if a human approves it inside approval_timeout_seconds. A denial or an expiry refuses the call — the gate fails closed.

mcp_servers:
  payments:
    mode: remote
    endpoint: https://payments.example.com/mcp
    tools:
      deny_list:
        - "internal_*"
      approval_list:
        - "refund_*"
        - "issue_credit"
      approval_timeout_seconds: 600
      approval_channel: slack       # optional; defaults to approvals.channel

With that config, list_transactions runs straight through, internal_reconcile is not exposed at all, and refund_payment is held for up to ten minutes while someone decides. deny_list wins over approval_list: a tool matched by both is hidden, not gated.

Pending approvals are listed and resolved over the REST API (GET /api/approvals, POST /api/approvals/{id}/resolve) — see REST API. Resolution requires the approval:resolve permission.

Notification is a separate concern from resolution. event_stream and noop ship in core; any other channel resolves from the mcp_hangar.approvals.delivery entry-point group. Each approval is delivered through the channel its policy names, so two servers can notify different places. See Approval delivery adapters.

Before 2.1.0 this key did nothing. approval_list existed on the internal policy object but no config parser read it, so a tools: block naming it parsed as no access policy at all and the matching tools ran ungated (#678). If you have such a block, it becomes live on upgrade — see Upgrade to 2.1.0.

Notification channels (approvals)

approvals:
  enabled: true            # the gate itself; on by default, inert until a policy gates a tool
  channel: event_stream    # the deployment default, used by any policy that names none
  delivery:
    required: false        # refuse to boot if a gated policy's channel notifies nobody
KeyTypeDefaultDescription
enabledbooltrueTurn the gate off entirely. See the interaction with startup_checks below
channelstrevent_streamChannel for any policy that does not name one
delivery.requiredboolfalseWhen true, a gated policy whose channel reaches nobody refuses the boot instead of logging at ERROR
<channel>map--Settings for an installed delivery adapter, keyed by its channel name and passed to it as-is. The 2.24.0 key check knows only slack and webhook here; any other name logs unknown_config_key

dashboard is an old name for event_stream; it still resolves, and logs approval_delivery_channel_renamed.

event_stream is the built-in channel: it does not push anywhere itself, because the notification already travels as a ToolApprovalRequested domain event on /api/ws/events, which any client holding audit:read can stream. A channel that has to reach somewhere else — Slack, a pager, a ticket queue — is an installed adapter.

noop reaches nobody by design. Choosing it, or naming a channel no installed package claims, leaves the gate armed and unmanned: held calls wait out approval_timeout_seconds and then deny, which looks exactly like a broken gateway from the client side. That is why the startup check reports it, and why delivery.required exists for deployments that would rather not start at all.

Before 2.7.0 approval_channel was a label. It was recorded on the approval and merged across scopes, but one global delivery handled every approval whichever policy raised it. A config setting different channels per server silently got one. Those configurations now route as written — check them before upgrading if you relied on the old behaviour.

Note the interaction with startup_checks: disabling the gate while a policy still names approval_list makes the server refuse to boot, rather than start and execute those calls ungated.

ui_resources

MCP Apps (SEP-1865) lets an upstream hand back a ui:// resource that a client renders in a webview — an execution and exfiltration surface, so Hangar gates it separately from ordinary resources and denies every ui:// resource by default.

ui_resources:
  tenants:
    "tenant:a":
      allowlist:
        - "ui://reports/"          # trailing "/" grants a path prefix
        - "ui://dash/q3"           # exact URI
        - "ui://widgets/*"         # explicit wildcard
      csp: "default-src 'none'; script-src 'self'"   # optional
KeyTypeDefaultDescription
tenants.<id>.allowlistlist[str][]ui:// URIs, path prefixes (trailing /) or wildcards (trailing *) this tenant may be served. Empty denies everything
tenants.<id>.cspstr(restrictive default)Content-Security-Policy attached to an allowed ui:// resource

Two independent gates have to pass before delivery, and both fail closed:

  1. The allowlist. A tenant with no entry — and a caller carrying no tenant — keeps the empty allowlist, so the resource is absent from resources/list and resources/read answers the way it does for a URI that does not exist. An unparseable entry is skipped with a warning, which leaves that tenant denied.
  2. Consent. An allowlisted ui:// resource is still held for a human decision on resources/read, delivered and resolved exactly like a tool approval. Consent cannot be switched off from the file: SEP-1865 mandates it, and it is the one hold that belongs on a fetch (ADR-024). A deployment with no approval gate wired therefore cannot deliver a ui:// resource at all.

Listing does not ask for consent — only delivery does, so a resources/list carrying ten allowlisted ui:// entries raises no prompts.

Before 2.13.1 neither half was reachable: no configuration built a policy and no consent gate was attached, so ui:// was denied whatever the config said (#1048).

startup_checks

At the end of bootstrap — the funnel serve, serve --http and the facade all pass through — Hangar checks that every subsystem the configuration demands is actually reachable on the path this process took. The question asked per subsystem is: the config demands it, and is the runtime object that serves it present?

startup_checks:
  enforce: true
KeyTypeDefaultDescription
enforcebooltrueWhen false, a fail-closed subsystem that is unreachable logs at ERROR instead of refusing the boot

Two outcomes:

  • Refuses the boot — a security subsystem the config demands is absent. The only one today is the approval gate: a tool on approval_list with no gate service raises a configuration error reading Configured subsystem is not reachable on this server: approval_gate required by tools.approval_list on <scope>. A gateway that cannot hold a call is a gateway executing it unapproved, so starting anyway would be failing open.
  • Logs at ERROR — everything else, e.g. the governed task relay enabled by relay_tasks_enabled with no governed task store, or a gated policy whose notification channel reaches nobody (approval_delivery). The event is subsystem_configured_but_unreachable, carrying subsystem, required_by and fail_closed.

approval_delivery is the one check that starts in the second group and can be moved to the first. The gate it belongs to is already fail-closed — a held call that nobody decides expires and is denied — so a missing notification is a missing signal, not missing enforcement, and refusing the boot over it would trade a degraded notify path for an outage. Set approvals.delivery.required: true to make it refuse anyway.

Setting enforce: false downgrades the refusals to error logs. There is deliberately no switch that makes an unreachable subsystem silent.

The approval-gate policies are read off the tool-access resolver rather than the raw YAML, so a policy introduced by hot reload or over REST is measured the same way as one from the config file.

Digest Pinning

Digest pinning validates tool schemas against precomputed SHA-256 digests. Since v1.3.0, digests are computed from RFC 8785 JSON Canonicalization Scheme (JCS) output. Recompute existing pins after upgrading from versions that used json.dumps canonicalization.

Unknown-tool handling uses DigestUnknownPolicy values (block, warn, allow_unverified):

  • block -- reject tools that do not have a known pinned digest.
  • warn -- allow unknown tools and emit a warning.
  • allow_unverified -- allow tools without a verified digest.

Mismatch handling (when a tool's schema does not match its pinned digest) uses the separate DigestEnforcement levels (audit, warn, block):

  • audit -- allow the mismatched tool and record the event for audit.
  • warn -- allow the mismatched tool and emit a warning.
  • block -- reject tools whose schema does not match the pinned digest.

allow_degraded was renamed to allow_unverified in v1.3.0. The old string is still accepted with a DeprecationWarning in v1.4.0, but new configuration should use only allow_unverified.

Digest computation also normalizes None, {}, [], and "" as absent values and rejects tool entries with a missing, empty, or non-string name field.

Since v1.4.0, digest pins are enforced on the live invocation path. Since v2.6.0 they can be declared for every caller as well as per tenant:

mcp_servers:
  payments:
    mode: remote
    endpoint: https://payments.example.com/mcp
    tool_projection:
      digest_enforcement: block
      pins:                                  # every caller, including an anonymous one
        refund: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
      tenant_overrides:
        "tenant:a":
          pins:                              # this tenant only; wins over the block above
            refund: fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
KeyTypeDefaultDescription
tool_projection.digest_enforcementstrblockPer-MCP server mismatch handling: audit, warn, or block
tool_projection.pinsdict[str, str]{}Tool name to 64-character lowercase SHA-256 digest pins applied to every caller
tool_projection.tenant_overrides.<tenant>.pinsdict[str, str]{}The same, for one tenant; takes precedence over tool_projection.pins

Pins are independent per MCP server, and resolution is narrowest first: a pin declared for the calling tenant wins over one declared for all tenants.

A per-tenant pin needs authentication. The tenant id reaches the enforcement path from the authenticated principal and from nowhere else, so on a gateway with auth.enabled: false every caller is anonymous, carries no tenant, and matches no per-tenant pin -- drift stays computable and nothing stops it. Since v2.6.0 that configuration refuses to start, naming the pins it found and the auth setting that makes them unmatchable. Use tool_projection.pins to pin without authentication; it holds every caller, including an anonymous one.

Re-checking pins against the upstream

Since 2.25.0 every READY server that a pin covers -- a pins or tenant_overrides.<tenant>.pins entry on the server, or on a group it is a member of -- is re-listed on an interval, set by a top-level key, not the per-server block above (mcp-hangar#1693):

tool_projection:
  pin_recheck_interval_s: 60   # the default; 0 turns the re-check off
KeyTypeDefaultDescription
tool_projection.pin_recheck_interval_sint60Seconds between re-listings of pinned servers. 0 turns it off; any other value must be 5--3600, and anything else, a string or a boolean included, refuses the boot. Read at start: a reload does not change it

A pinned tool the upstream changed without sending tools/list_changed is refused (ToolDigestMismatchError, gate reason digest_mismatch) from the next call after the pass that sees it -- at most one interval, plus the time the pass takes to list the pinned servers, after the change. The pass logs tool_digest_pin_drift_detected at warning and publishes one DigestMismatchEvent when it first sees the drift. Each pass sends one tools/list per pinned server to its upstream, from every replica. Servers that are cold, starting, degraded or DEAD are not listed and not started; a listing that fails keeps the previous catalogue. A re-list refreshes the rest of the catalogue too, so a tool added without an announcement is seen within an interval, and a pinned server whose capabilities enforcement blocks or quarantines a tool outside expected_tools is blocked at its next call.

Before 2.25.0 the catalogue a pin was checked against was refreshed only at start and on tools/list_changed, so an unannounced change was served under the old pin until the gateway restarted.

Header Exposure

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. The spec's only defence against annotating a secret is a SHOULD NOT, so an upstream that annotates api_key obliges every client to put the key in front of every intermediary on the path.

Since v2.14.0, header_exposure sits beside tool_projection on an MCP server or a group and says which parameter names you are willing to have exposed that way:

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
KeyTypeDefaultDescription
header_exposure.deny_annotatedlist[str][]Globs matched case-insensitively against both the x-mcp-header token and the property path
header_exposure.on_violationstrwarnwarn, withdraw, or refuse_boot. Any other value is rejected at parse

warn serves the tool and logs; withdraw withholds it from tools/list and answers -32601 on the call; refuse_boot refuses to serve the catalogue at all. The default is warn, so adding the block does not change what a client sees until you choose otherwise.

An unknown on_violation refuses the configuration rather than falling back to the default -- the same contract as an unknown arguments.secretPatterns group in an egress policy, and for the same reason: a control that reports as enforcing while its action never fires is worse than one that is off.

The block is a config overlay, so deleting it and reloading restores whatever it withheld. A group member inherits the block declared on its group.

Independently of any configuration, a tool whose x-mcp-header annotations are syntactically invalid is withheld from the front-door projection since v2.14.0 -- a conforming client drops it on arrival, so advertising it handed out a tool nobody could call. Both controls are counted by mcp_hangar_projection_withdrawals_total{reason}.

See the front-door guide for the full behaviour, including why the projected schema is never edited.

headers

What Hangar does with an Mcp-Param-* value it could not check against the request body. The mcp SDK validates that agreement before dispatch and is fail-open by design: when the called tool's schema cannot be resolved, the check is skipped and the call is dispatched anyway.

Since v2.14.0 an MCPEgressPolicy can select on those headers, so this is no longer somebody else's problem. Independently of any configuration, a header selector does not match a request whose validation was skipped -- it falls through to the tool rules and the policy default. This section is the opt-in control above that: refuse the call outright instead of serving it.

headers:
  param_validation:
    required: true      # default: false
KeyTypeDefaultDescription
headers.param_validation.requiredboolfalseRefuse a tools/call whose Mcp-Param-* headers could not be validated, with HEADER_MISMATCH (-32020), instead of serving it. A non-boolean refuses to start

The block is global rather than per-server: the condition is a failed listing on this request, not a property of the upstream the call would reach, so it does not belong beside the per-server header_exposure block even though the two govern the same SEP.

Turning it on converts an upstream availability problem into a client-visible refusal for every call carrying header parameters, whether or not a policy selects on them -- which is why it is opt-in while the non-match is not. Skips are counted by mcp_hangar_param_header_validation_skipped_total{reason}.

See ADR-025 and the front-door guide.

execution

System-wide concurrency limits.

execution:
  max_concurrency: 50
  default_mcp_server_concurrency: 10
KeyTypeDefaultRangeDescription
max_concurrencyint500 = unlimitedSystem-wide maximum concurrent tool invocations
default_mcp_server_concurrencyint100 = unlimitedDefault per-MCP server concurrency limit
max_response_bytesint33554432 (32 MiB)> 0Largest upstream response either transport reads before failing the call with ResponseTooLarge, for every server without its own max_response_bytes. MCP_MAX_RESPONSE_BYTES wins over it
tenant_limitsdict[str, dict]----Per-tenant execution budgets, keyed by tenant id (see below)

A negative limit, or a max_response_bytes that is not a positive integer, refuses the config.

tenant_limits

execution:
  tenant_limits:
    "tenant:a":
      max_concurrency: 5
      rps: 2
      burst: 10
    "*":
      max_concurrency: 2
      rps: 1
      burst: 5

Each entry needs exactly max_concurrency (integer, at least 1), rps (a number above 0) and burst (integer, at least 1); a missing or extra key refuses the config. A listed tenant gets its own budget. "*" is a template: every unlisted tenant gets a budget of its own built from it, and callers with no tenant share one. With no "*" entry, an unlisted tenant and a caller with no tenant are refused. A call over its budget is refused at once with TenantQuotaExceeded, never queued. Budgets are counted per replica.

discovery

Auto-discovery of MCP servers from external sources.

discovery:
  enabled: true
  refresh_interval_s: 60
  auto_register: false
  sources:
    - type: docker
      mode: additive
    - type: filesystem
      mode: additive
      path: /etc/mcp/mcp_servers
      watch: true
KeyTypeDefaultRangeDescription
enabledboolfalse--Enable or disable discovery
refresh_interval_sint30--Interval between discovery scans in seconds
auto_registerbooltrue--Automatically register discovered MCP servers
sourceslist[dict][]--Discovery source configurations (see below)
securitydict----Security constraints for discovery
lifecycledict----Lifecycle management for discovered MCP servers. Read, but missing from the 2.24.0 key check: it logs unknown_config_key, and strict mode and config check refuse it

sources[] entry

Only type and mode are read by Hangar itself. Every other key is passed to that source's factory untouched — which is what lets a third-party source be configured here without core knowing its option names.

type: kubernetes needs the Kubernetes Python client, which is an extra: pip install mcp-hangar[kubernetes]. The published image ships it. Without it Hangar starts, logs discovery_source_unavailable, and this source discovers nothing.

KeyTypeDescription
typestrSource type: kubernetes, docker, filesystem, entrypoint, or any type registered under the mcp_hangar.discovery_sources entry point group. An unregistered type fails startup
modestradditive (only adds, the default) or authoritative (adds and removes). Any other value fails startup
pathstrDirectory to scan (filesystem source), default /etc/mcp-hangar/mcp_servers.d/; a relative path is taken from the working directory
patternstrFile glob inside path (filesystem source), default *.yaml
watchboolEnable file watching (filesystem source), default true
socket_pathstrDocker/Podman socket (docker source)
namespaceslist[str]Kubernetes namespaces to scan
label_selectorstrKubernetes label selector
in_clusterboolUse in-cluster Kubernetes config, default true
allowed_namespaceslist[str]Kubernetes namespace allowlist; empty means "everything not denied"
denied_namespaceslist[str]Kubernetes namespace denylist, default [kube-system, default]. Wins over the allowlist
groupstrPython entry point group to read servers from (entrypoint source), default mcp.mcp_servers

security sub-section

Constraints applied to every source, whatever it discovers.

KeyTypeDefaultDescription
allowed_namespaceslist[str]--Deprecated — moved to the kubernetes source entry above. Still honoured, and logs discovery_namespace_policy_deprecated_location; the source's own setting wins when both are present
denied_namespaceslist[str]--Deprecated — see allowed_namespaces
require_health_checkbooltrueRequire health check before registration
require_mcp_schemaboolfalseRequire valid MCP schema
max_mcp_servers_per_sourceint100Maximum MCP servers per source
max_registration_rateint10Registrations per minute
health_check_timeout_sfloat5.0Health check timeout in seconds
quarantine_on_failurebooltrueQuarantine MCP servers that fail health checks

lifecycle sub-section

KeyTypeDefaultDescription
default_ttl_sint90Default TTL for discovered MCP servers
check_interval_sint10Lifecycle check interval in seconds
drain_timeout_sint30Drain timeout before removal

retry

Retry policy for failed tool invocations.

Scope: hangar_call. The policy is consulted by the batch executor behind that tool, which serves a single call and a batch alike. It governs nothing else: discovery, health checks and the HTTP transport have their own settings (see the http block's max_retries for the transport-level one).

The tool argument max_attempts can lower the attempt count for a single call but never raise it above what is configured here: the operator decides how hard this gateway leans on an upstream. With no retry block at all, a call is attempted once unless the caller asks for more.

Refusals are never retried, whatever retry_on says -- access and egress denials, approval-required holds, authentication and authorization failures, rate limits and validation errors. Retrying a decision asks the same question again, and re-driving an approval gate holds a human decision open once per attempt.

Consulted since 2.17.1; before that the block was parsed and logged but no code read it back.

retry:
  default_policy:
    max_attempts: 3
    backoff: exponential
    initial_delay: 1.0
    max_delay: 30.0
    retry_on:
      - ConnectionError
      - TimeoutError
  per_mcp_server:
    sqlite:
      max_attempts: 5
    fetch:
      max_attempts: 2
KeyTypeDefaultDescription
default_policy.max_attemptsint3Maximum retry attempts
default_policy.backoffstrexponentialBackoff strategy: exponential, linear, or constant
default_policy.initial_delayfloat1.0Initial delay in seconds
default_policy.max_delayfloat30.0Maximum delay in seconds
default_policy.retry_onlist[str]transient errorsException type names to retry on. Unset means MalformedJSON, JSONDecodeError, Timeout, TimeoutError, ConnectionError, McpServerNotResponding, TransientError, McpServerProtocolError and NetworkError
default_policy.jitterbooltrueAdd random jitter to each delay
default_policy.jitter_factorfloat0.25Jitter range as a fraction of the delay (0.25 = ±25%)
per_mcp_serverdict[str, dict]{}Per-server overrides keyed by MCP server ID; each value takes the same keys as default_policy and is merged over it (unset keys inherit the default)

truncation

Batch-response truncation with continuation IDs. Opt-in. When enabled, hangar_call batch responses that exceed the size budget are truncated; each truncated result carries a continuation_id, and the full payload is held in a cache for hangar_fetch_continuation / hangar_delete_continuation to retrieve or drop. A truncated result only advertises a continuation_id when the full payload was actually stored -- a cache write that fails yields a truncated result with no continuation rather than an ID that cannot be fetched.

truncation:
  enabled: true
  max_batch_size_bytes: 900000
  min_per_response_bytes: 10000
  cache_ttl_s: 300
  cache_driver: memory        # or: redis
  # redis_url: redis://redis:6379/0   # required when cache_driver: redis
  max_cache_entries: 10000
  preserve_json_structure: true
  truncate_on_line_boundary: true
KeyTypeDefaultDescription
enabledboolfalseEnable truncation (opt-in)
max_batch_size_bytesint900000Maximum total batch response size in bytes
min_per_response_bytesint10000Minimum bytes allocated to each response in a batch; must not exceed max_batch_size_bytes
cache_ttl_sint300TTL for cached full responses, in seconds
cache_driverstrmemoryContinuation cache backend: memory or redis
redis_urlstr--Redis connection URL; required when cache_driver: redis
max_cache_entriesint10000Maximum entries held by the memory cache
preserve_json_structurebooltrueTruncate JSON payloads without breaking JSON validity
truncate_on_line_boundarybooltrueTruncate text payloads at line boundaries

Choosing the cache driver

memory is per-replica. A continuation minted on one replica is only fetchable on the replica that truncated the response. On a single instance that is correct and needs no Redis. On a coordinated deployment (a coordination: block) it is legal but almost certainly wrong -- Hangar logs truncation_memory_cache_is_per_replica at boot to say so. See Running more than one replica.

redis is shared, and it fails closed. When the operator asked for Redis, Hangar refuses to boot if Redis is unusable: the redis package missing, a bad redis_url, or a server that cannot serve a SETEX (the boot probe is a real SETEX round trip, not a PING, so a Sentinel listen port cannot pass for a working store). There is no silent fallback to the in-memory cache; the boot log names the backend actually in use. Install the client with the [redis] extra (pip install mcp-hangar[redis]) -- the published container image already ships it.

interceptors

Opt-in built-in validators that gate every tools/call on the invoke path. Off by default: an absent or empty section registers no validators. Each entry names a built-in validator type; the remaining keys are passed to that validator. A spec naming an unknown type, or missing type entirely, refuses startup rather than silently skipping the guard.

interceptors:
  validators:
    - type: payload_size
      max_bytes: 1000000
KeyTypeDefaultDescription
validatorslist[dict][]Validator specs, registered in source order; each is a dict with a type key plus per-type parameters
validators[].typestrrequiredBuilt-in validator name; the only one today is payload_size

payload_size validator

Denies a tools/call whose JSON-encoded payload exceeds a byte cap, guarding upstream servers from oversized arguments. Fail-closed: a payload that cannot be serialized for measurement is denied too.

KeyTypeDefaultDescription
max_bytesint1000000Maximum JSON-encoded payload size in bytes; larger requests are denied

hot_loading

Registry-backed on-demand loading: hangar_load resolves a server from an MCP registry, installs its package (uvx/npx) and starts it at runtime; hangar_unload reverses it. Enabled by default; enabled: false switches the capability off entirely. Requires httpx; without it hot loading is unavailable and Hangar logs hot_loading_unavailable at boot.

hot_loading:
  enabled: true
  registry:
    base_url: https://registry.modelcontextprotocol.io/v0
    timeout_s: 10.0
    max_retries: 3
  cache:
    ttl_s: 3600
    max_entries: 1000
KeyTypeDefaultDescription
enabledbooltrueEnable runtime loading from the registry
registry.base_urlstrhttps://registry.modelcontextprotocol.io/v0Registry API base URL
registry.timeout_sfloat10.0Registry request timeout in seconds
registry.max_retriesint3Retry attempts for registry requests
cache.ttl_sint3600TTL for cached registry lookups, in seconds
cache.max_entriesint1000Maximum entries in the registry lookup cache

event_store

Persist domain events for audit and replay.

event_store:
  enabled: true
  driver: sqlite
  path: data/events.db
  allow_memory_fallback: false
KeyTypeDefaultDescription
enabledbooltrueEnable event persistence
driverstrsqliteStorage driver: sqlite or memory. Any other value refuses to start
pathstrdata/events.dbSQLite database path (sqlite driver only)

With a persistence backend selected, the event log lives in that backend. | allow_memory_fallback | bool | false | Permit degrading to an in-memory store when a durable driver cannot initialize, instead of refusing to start |

Durable-store fail-fast

When a durable driver (sqlite) cannot initialize its store -- for example the path is not writable -- Hangar refuses to start rather than silently degrading to a non-durable in-memory store. To opt into the fallback, set driver: memory or allow_memory_fallback: true.

If the store degrades to in-memory while a durable driver was configured, /health/ready returns 503 (the process stays live, but reports not-ready) so an orchestrator does not route traffic to an instance that is silently dropping its audit trail.

persistence

Since 2.5.0. One storage decision for everything the gateway keeps: the event log and its delivery mark, server configuration, the audit trail, saga state, approvals, API keys, roles, tool-access policies, metric history and the management lease.

persistence:
  backend: postgresql
  postgresql:
    host: db.internal.example
    port: 5432
    user: hangar
    password: ${HANGAR_DB_PASSWORD}
    database: mcp_hangar
KeyTypeDefaultDescription
backendstr--sqlite or postgresql. Omit the block entirely and each subsystem configures its own storage as before
<backend name>map--Passed to that backend untouched. data_dir means nothing to PostgreSQL and host means nothing to SQLite

The block under the backend's own name is handed to its factory as-is, so the two never have to know each other's vocabulary.

A backend serves every concern or it is refused. Selection checks all of them and names every missing one at once. A backend cannot be half-configured: that is what made it possible to select PostgreSQL and silently lose tool-access policy management before 2.5.0.

A contradiction is refused rather than resolved. Selecting a backend while a legacy per-subsystem key names a different one fails at startup. Every precedence rule silently ignores half of what you wrote, and the half that loses is the one you wrote most recently. memory is exempt -- it is a testing choice rather than a storage backend.

Which one to choose. sqlite is the standalone answer: files under one directory, nothing to install, nothing to run, and not shareable between processes. postgresql is the only one several gateways can share, and is therefore required for more than one replica -- see coordination below and Running more than one replica.

postgresql needs its driver installed. psycopg2 is an extra rather than a base dependency -- pip install mcp-hangar[postgres]. The published image ships it, so image and chart deployments are unaffected; a plain pip install mcp-hangar is not, and the first connection raises psycopg2 is required for PostgreSQL.

No migration is provided between backends. Selecting PostgreSQL on a gateway that has been running on SQLite starts an empty database.

coordination

Since 2.5.0. Declares that these replicas are meant to be one gateway rather than several independent ones. Present only for multi-replica deployments.

coordination:
  lease_ttl_s: 15
  renew_interval_s: 5
  renew_deadline_s: 10
KeyTypeDefaultDescription
lease_ttl_sfloat15.0How long a management tenure lasts without renewal
renew_interval_sfloat5.0How often the holder renews, and how often a follower retries
renew_deadline_sfloat10.0How long this instance will go without a successful renewal before it gives the lease up on its own. Must be under lease_ttl_s

The defaults mirror Kubernetes leader election. Shorter values replace a dead leader sooner and make a garbage-collection pause likelier to cost a live one its lease; a fleet whose manager keeps changing converges worse than one whose manager is occasionally slow.

renew_deadline_s is deliberately under the TTL. An unreachable database is not an answer: the tenure expires on its clock whether or not this instance can read it, so the instance gives up slightly early rather than slightly late.

This block requires a shareable backend. Configuring it with sqlite refuses to start, because replicas that cannot share storage are not a cluster -- each would hold its own fleet and its own lease and they would never notice each other. Either use postgresql, or remove the block to run this as a single gateway.

logging

Log output configuration.

logging:
  level: INFO
  json_format: false
  file: /var/log/mcp-hangar.log
KeyTypeDefaultDescription
levelstr"INFO"Log level: DEBUG, INFO, WARNING, ERROR
json_formatboolfalseEnable structured JSON logging
filestr--Log file path

--log-level, --log-file and --json-logs (and MCP_LOG_LEVEL / MCP_JSON_LOGS) win over this block, with one exception: a level of INFO cannot be told apart from the default, so logging.level wins over it.

observability

Tracing and OTLP audit export.

tracing sub-section

observability:
  tracing:
    enabled: true
    otlp_endpoint: http://localhost:4317
    service_name: mcp-hangar
    console_export: false
KeyTypeDefaultDescription
enabledbooltrueEnable OpenTelemetry tracing
otlp_endpointstr"http://localhost:4317"OTLP exporter endpoint
service_namestr"mcp-hangar"Service name for traces
jaeger_hoststr--Jaeger agent host
jaeger_portint6831Jaeger agent port
console_exportboolfalseExport traces to console (development)
caller_idsboolfalsePut user, agent and session ids on spans. MCP_TRACING_CALLER_IDS wins over it

The environment variables in Observability / Tracing win over these keys.

audit sub-section

observability:
  audit:
    enabled: true
KeyTypeDefaultDescription
enabledbooltrueExport audit records as OTLP logs. They go only to an endpoint set explicitly, in tracing.otlp_endpoint or OTEL_EXPORTER_OTLP_ENDPOINT, never to the default. Independent of tracing.enabled. MCP_AUDIT_EXPORT_ENABLED wins over it

langfuse sub-section (removed in 2.25.0)

observability.langfuse is removed with the Langfuse adapter, which nothing called after 2.22.0 (mcp-hangar#1683). scrub_inputs or scrub_outputs in the block refuses the boot, whatever its value; any other key in it is reported as unknown -- a warning, or a refusal under HANGAR_CONFIG_STRICT and in mcp-hangar config check. Delete the block. Langfuse takes Hangar's spans over OTLP; see Langfuse.

auth

Authentication and authorization.

auth:
  enabled: true
  allow_anonymous: false
  api_key:
    enabled: true
    header_name: X-API-Key
  oidc:
    enabled: false
    issuer: https://auth.example.com
    audience: mcp-hangar
    resource_uri: https://hangar.example.com
    issuers:
      - issuer: https://issuer-a.example.com
        audience: https://hangar.example.com
        jwks_uri: https://issuer-a.example.com/jwks
  rate_limit:
    enabled: true
    max_attempts: 10
    window_seconds: 60
    lockout_seconds: 300

Note: The auth.rate_limit block above configures login-attempt lockout (failed authentication throttling), distinct from the runtime command-bus token-bucket limiter that throttles command dispatch.

Since 1.5.0 the command-bus token-bucket limiter is configurable via a top-level rate_limit block; its values take precedence over the MCP_RATE_LIMIT_RPS / MCP_RATE_LIMIT_BURST environment variables (which remain a fallback). Omit the block to keep the env/default behavior (10 rps, burst 20):

rate_limit:
  rps: 10     # tokens refilled per second (default 10)
  burst: 20   # burst capacity / bucket size (default 20)
  per_caller: # optional; off when absent
    rps: 2
    burst: 5

That budget is shared by every caller. per_caller adds a budget of each caller's own under it, a caller being its tenant and principal (every anonymous caller counts as one). It needs both rps (a number above 0) and burst (an integer of at least 1), and anything else refuses the config. The listing and inspection tools are not charged. Both budgets are per replica.

KeyTypeDefaultDescription
enabledboolfalseEnable authentication
allow_anonymousboolfalseAllow unauthenticated requests
api_key.enabledbooltrueEnable API key authentication
api_key.header_namestrX-API-KeyHTTP header name for API key
oidc.enabledboolfalseEnable OpenID Connect authentication
oidc.issuerstr--OIDC issuer URL
oidc.audiencestr--Expected token audience
oidc.jwks_uristr--JWKS endpoint
oidc.client_idstr--Optional client ID for additional validation
oidc.resource_uristr--Public resource URI. When set, this is advertised in RFC 9728 metadata and enforced as JWT aud for all issuers.
oidc.issuerslist[dict][]Multi-issuer trust entries. When non-empty, these override the legacy top-level oidc.issuer.
oidc.issuers[].issuerstrinheritedTrusted OIDC issuer URL
oidc.issuers[].audiencestrinheritedExpected audience when resource_uri is unset
oidc.issuers[].jwks_uristrinheritedJWKS endpoint for this issuer
oidc.issuers[].client_idstrinheritedOptional client ID for additional validation
oidc.issuers[].max_token_lifetime_secondsintinheritedMaximum JWT lifetime for this issuer; 0 disables the check
oidc.issuers[].<other>--inheritedEach issuer may also override subject_claim, groups_claim, email_claim, tenant_claim, session_id_claim, require_tenant, strict_tenant_audience and tenant_audiences
oidc.subject_claimstrsubJWT subject claim field
oidc.groups_claimstrgroupsJWT groups claim field
oidc.email_claimstremailJWT email claim field
oidc.tenant_claimstrtenant_idJWT tenant claim field
oidc.session_id_claimstrsidJWT session id claim field
oidc.max_token_lifetime_secondsint3600Maximum accepted JWT lifetime (exp - iat); 0 disables the check. MCP_JWT_MAX_TOKEN_LIFETIME wins over it
oidc.clock_skew_leeway_secondsint60Leeway applied to exp/nbf/iat validation to absorb clock skew between Hangar and the issuer
oidc.require_tenantboolfalseFail-closed multi-tenant gate. When true, a trusted token whose tenant claim is missing or empty is rejected instead of falling back to an untenanted principal
oidc.strict_tenant_audienceboolfalseOpt-in strict per-tenant audience binding (RFC 8707): the token's audience must match the audience mapped to its claimed tenant in tenant_audiences, so a token minted for one tenant cannot be replayed as another
oidc.tenant_audiencesdict[str, str]{}Explicit tenant -> expected audience/resource URI map, used when strict_tenant_audience is true
opa.enabledboolfalseEnable Open Policy Agent authorization
opa.urlstrhttp://localhost:8181OPA server URL
opa.policy_pathstrv1/data/mcp/authz/allowOPA policy path
opa.timeoutfloat5.0OPA request timeout in seconds
storagedictdriver: memoryAuth storage configuration (driver, path, host, etc.)
rate_limitdict--Auth-specific rate limiting
role_assignmentslist[dict]--Role assignment rules
stdio.principaldict--The caller a stdio session is declared to be (see below). Ignored over HTTP

auth.stdio.principal -- who the caller is over stdio

Since 2.18.0.

A stdio server is not listening on anything. It was spawned -- by Claude Code, Cursor, Claude Desktop or a shell -- as a child of a session the operating system already authenticated, and a credential kept in the same file that declares the principal would authenticate the file rather than the caller. So this block declares an identity and Hangar checks nothing:

auth:
  stdio:
    principal:
      id: local-user      # required
      tenant_id: local    # required
      roles: [viewer]     # optional; defaults to [viewer]

Why it matters: identity reaches Hangar through HTTP middleware, which a stdio process never enters. Without this block a stdio caller is anonymous, and tool_access.mode: front_door -- which is fail-closed on identity -- serves it zero tools:

empty_projection reason=no_identity -- front_door served zero tools because the
caller carried no tenant identity. Fail-closed deny, not an empty catalogue.
KeyTypeDefaultDescription
idstr--Principal id. Required; a block without one is ignored entirely
tenant_idstr--Tenant the caller belongs to. Scopes tool-access rules and per-tenant pins. Required
roleslist[str][viewer]Roles resolved through the ordinary RBAC table. viewer is read-only; [] projects no hangar_* tools at all

Behaviour worth knowing:

  • HTTP ignores the block. It has a credential channel and keeps using it.
  • Absent, nothing changes. The caller is anonymous, exactly as before 2.18.0.
  • The management surface follows roles. With viewer, a client sees the read-only hangar_* tools (hangar_status, hangar_health, hangar_list, …) and none that can start, stop or reconfigure anything. Calling the upstreams' own tools does not depend on those roles.
  • Per-tenant pins for this tenant become matchable. Hangar refuses at boot when per-tenant pins are declared that no caller could carry; a declared stdio principal is such a caller, for its own tenant_id.
  • Anyone who can write this file can name any principal. On a laptop that is the same person. On a shared host, the file's permissions are the control.

The decision and its trade-offs are recorded in ADR-026.

config_reload

Hot-reload configuration. See the Hot-Reload Reference for full details.

config_reload:
  enabled: true
  use_watchdog: true
  interval_s: 5
KeyTypeDefaultDescription
enabledbooltrueEnable automatic config file watching
use_watchdogbooltrueUse watchdog library for file system events
interval_sint5Polling interval in seconds (fallback when watchdog unavailable)

groups

MCP Server groups are configured inside the mcp_servers section with mode: group. A group load-balances requests across multiple member MCP servers.

mcp_servers:
  llm-group:
    mode: group
    strategy: round_robin
    min_healthy: 1
    auto_start: true
    description: LLM mcp_server pool
    health:
      unhealthy_threshold: 2
      healthy_threshold: 1
    circuit_breaker:
      failure_threshold: 10
    tools:
      allow_list: ["generate_*"]
    canary:
      member: llm-2
      split_pct: 10
      pinned_tenants:
        "tenant:beta": llm-2
    members:
      - id: llm-1
        mode: subprocess
        command: [python, -m, llm_server]
        weight: 70
        priority: 1
      - id: llm-2
        mode: subprocess
        command: [python, -m, llm_server]
        weight: 30
        priority: 2
KeyTypeDefaultRangeDescription
modestr--"group"Must be "group"
strategystr"round_robin"round_robin, weighted_round_robin, least_connections, random, priorityLoad balancing strategy. An unknown value logs unknown_strategy_using_default and uses round_robin
min_healthyint1>= 1Members in rotation, not dead, needed for the group healthy state and to close an open circuit
auto_startbooltrue--Auto-start members when the group is created
descriptionstr----Group description
health.unhealthy_thresholdint2>= 1Consecutive failures before removing member from rotation
health.healthy_thresholdint1>= 1Consecutive successes before re-adding member to rotation
circuit_breaker.failure_thresholdint10>= 1Failures in a row, across the group's members, before the circuit opens. A success ends the run. The circuit has no reset timer: it closes once min_healthy members are back in rotation
toolsdict----Group-level tool access policy (allow_list, deny_list, approval_list, approval_timeout_seconds, approval_channel) -- see tools dual format
canary.memberstr----Member that receives canary split traffic
canary.split_pctint00--100Deterministic percentage of tenants routed to canary.member
canary.pinned_tenantsdict[str, str]{}--Tenant ID to member ID pins; explicit pins win over split routing
memberslist[dict][]--Member MCP server configurations
max_concurrencyint-->= 0Concurrency limit for calls that name the group
access, tool_projection, header_exposuredict----The same blocks as on a server, applied to the group

A group's circuit closes once min_healthy members are back in rotation, after a passing health check or a completed start; it has no timer. circuit_breaker.reset_timeout_s was removed in 2.20.0 because it never had an effect. A config that still sets it loads and logs unknown_config_key; HANGAR_CONFIG_STRICT=1 and mcp-hangar config check refuse it. See MCP Server Groups → Circuit Breaker.

Member configuration

A member whose id names a server declared under mcp_servers is that server: its entry reads only the keys below, and any other key logs group_member_entry_settings_ignored. A member with no such server is built from its own entry, which then takes all standard MCP server keys (mode, command, image, endpoint, env, etc.) and must say how to run it, or the config is refused. The member keys are:

KeyTypeDefaultRangeDescription
idstr----Unique member ID (required)
weightint11--100Weight for weighted_round_robin and random strategies
priorityint11--100Priority for priority strategy (lower number = higher priority)
toolsdict----Member-level tool access policy, same keys as the group-level block

Bounds how many handed-out resource_link references the front door remembers per tenant. A projected link is what a caller redeems to read an upstream resource, so the map has to be bounded or a caller that mints links forever exhausts the replica.

resource_links:
  max_per_tenant: 4096
FieldTypeDefaultDescription
max_per_tenantint4096Remembered links per tenant. Must be a positive integer; omit the key to keep the default.

Eviction is per tenant and oldest-first, so one tenant's traffic cannot forget another's links. Redeeming a link that has been evicted fails as if it never existed, which is the trade the cap buys.

Watch mcp_hangar_resource_links_evicted when tuning it. Its reason label separates the two causes: tenant_cap (a tenant hit its own cap) from tenant_map_cap (a whole tenant was dropped because too many tenants were seen). Sustained tenant_cap means this value is too low for the workload; tenant_map_cap means identity churn, not link volume.

The map is per replica and in memory, so links do not survive a restart and are not readable from another replica.

tool_access

Selects the gateway topology, and in front_door what a replica must have projected before it reports ready.

tool_access:
  mode: front_door
  required_catalogue:
    servers: [payments, search-pool]
    retry_for_s: 600
KeyTypeDefaultDescription
modestregressegress or front_door. Any other value refuses the config. A reload cannot change it
required_catalogue.serverslist[str]--Server or group ids that must have been projected once before /health/ready answers 200. A group is met by any one member. Must be a non-empty list of ids declared in mcp_servers
required_catalogue.retry_for_sfloat600How long readiness waits for the list, and the missing servers are retried, counted from the first config apply. Must be above 0

required_catalogue applies in front_door only; in egress it is checked and ignored. Where several replicas take the management lease, a required server in a local mode (subprocess or docker) refuses the config, because only the lease holder may start one. See the front-door guide.

http

http:
  graceful_shutdown_timeout_s: 90
KeyTypeDefaultDescription
graceful_shutdown_timeout_sint(unset)How long serve --http waits for in-flight requests once told to stop, before cancelling them. Unset waits without a bound. Must be a positive integer. Read when the HTTP server starts, so a reload checks it but does not apply it

ssrf

ssrf:
  trusted_networks: ["10.96.0.0/12"]
KeyTypeDefaultDescription
trusted_networkslist[str][]Private networks (CIDRs) that a remote endpoint registered through POST /api/mcp_servers may resolve into. An address in a private range passes only inside one of them. Both the registration check and the per-request connect-time check read the list, so a reload that narrows it refuses the next connection. Since 2.27.0

Without the section, the API refuses every private endpoint, as before. The Kubernetes operator needs it to register a mode: remote MCPServer whose endpoint is an in-cluster Service; list the cluster's Service range.

  • Who it applies to: every caller allowed to register a server (mcp_servers:write), not only the operator. Keep the list as narrow as the servers need.
  • What the Service range holds: the kube-apiserver's ClusterIP and every other Service in the cluster. Add the pod range only for headless Services.
  • What it never covers: loopback, link-local (the cloud metadata endpoint among it) and the unspecified ranges are refused at startup and on reload, and so is a CIDR with host bits set (10.96.0.1/12). A name that resolves to a trusted and an untrusted private address is refused.
  • Endpoints declared in config.yaml: not affected; see mode: remote and the SSRF policy.

With the Helm chart, set it through extraConfig:

extraConfig:
  ssrf:
    trusted_networks: ["10.96.0.0/12"]

relay_tasks_enabled

A top-level boolean, default true: the kill switch for the governed task relay. See Governed tasks.

Unknown keys

A key Hangar does not read is kept and ignored. It does not fail, and it does not warn about anything except itself -- so the setting simply never applies, and the consequence turns up somewhere else:

mcp_servers:
  math:
    mode: subprocess
    commandd: [python, -m, server]   # a server that will not start
    idle_tt1_s: 60                   # a digit for an l; idle stop never happens

auth:
  enabledd: true                     # authentication is off

Each of those loads. The first reads as a broken server, the second as a memory leak, and the third as nothing at all until someone tries an unauthenticated request.

Hangar reports these on load:

unknown_config_key  detail=auth has unknown key(s) ['enabledd']; allowed keys:
['allow_anonymous', 'api_key', 'enabled', 'oidc', 'opa', 'rate_limit',
'role_assignments', 'stdio', 'storage']

Throughout 2.x it warns and starts. From 3.0.0 it refuses. Set HANGAR_CONFIG_STRICT=1 to refuse now -- worth doing in CI and in staging. Any unknown_config_key warning in your logs is a configuration that will not load after the 3.0.0 upgrade.

To check a file without starting a gateway, use mcp-hangar config check, which is always strict and exits non-zero on an unknown key.

What is checked

Top-level section names, the direct keys of each section, and the keys of an mcp_servers.<id> spec. Not keys deeper than that: below that level there is no single place in the product that enumerates them, and a schema assembled from twenty readers would drift into rejecting valid configuration -- a worse failure than accepting a typo.

truncation is the exception at section level: its keys are not checked. Some blocks below that level check their own keys and refuse an unknown one whatever the strict setting: execution.tenant_limits, rate_limit.per_caller and tool_access.required_catalogue.

One thing the check will not catch, because both spellings are real: rate_limit exists at the top level, where it takes rps and burst, and under auth, where it does not. Nesting it in the wrong place gives you a valid config that throttles nothing.

Environment Variables

Environment variables override corresponding YAML settings. Variables follow the MCP_ prefix convention. Third-party integrations (OpenTelemetry, Langfuse, Jaeger) use their standard prefixes.

Server / CLI

VariableDefaultDescription
MCP_CONFIG--Path to YAML configuration file. A --config on the command line wins over it; without either, ./config.yaml if present, else ~/.config/mcp-hangar/config.yaml
MCP_MODE"stdio"Server mode: stdio or http
MCP_HTTP_HOST"0.0.0.0"HTTP bind host. A non-loopback host without authentication refuses to start unless --unsafe-no-auth is passed
MCP_HTTP_PORT8000HTTP bind port
MCP_LOG_LEVEL"INFO"Logging level (DEBUG, INFO, WARNING, ERROR)
MCP_JSON_LOGS"false"Enable structured JSON logging
HANGAR_CONFIG_STRICT--1, true, yes or on refuses a config with an unknown key instead of warning. See Unknown keys

Security / Runtime

VariableDefaultDescription
MCP_RATE_LIMIT_RPS"10"Rate limit: requests per second
MCP_RATE_LIMIT_BURST"20"Rate limit: burst size
MCP_ALLOW_ABSOLUTE_PATHS"false"Allow absolute paths in input validation
MCP_MAX_RESPONSE_BYTES--Wins over execution.max_response_bytes; a server's own max_response_bytes wins over both

Persistence

VariableDefaultDescription
MCP_PERSISTENCE_ENABLED"false"Enable state persistence
MCP_DATABASE_PATH"data/mcp_hangar.db"SQLite database file path
MCP_DATABASE_WAL"true"Enable WAL mode for SQLite
MCP_AUTO_RECOVER"true"Auto-recover persisted state on startup
HANGAR_INSTANCE_LABELhostnameSince 2.5.0. Prefix for this instance's identity, which appears on every event it produces. Under Kubernetes, set it from the downward API (metadata.name); the hostname is that same pod name, which is why it is the fallback. It is a label, not the identity: a per-process suffix is always appended, so replicas rolled from one ConfigMap cannot end up sharing an id

Observability / Tracing

VariableDefaultDescription
MCP_TRACING_ENABLED"true"Enable OpenTelemetry tracing
MCP_TRACING_CONSOLEfrom configEnable console trace export
MCP_TRACING_CALLER_IDSfrom configPut user, agent and session ids on spans
MCP_AUDIT_EXPORT_ENABLEDfrom configExport audit records as OTLP logs (observability.audit.enabled)
MCP_ENVIRONMENT"development"Deployment environment label
OTEL_EXPORTER_OTLP_ENDPOINT"http://localhost:4317"OTLP exporter endpoint
OTEL_SERVICE_NAME"mcp-hangar"OpenTelemetry service name
JAEGER_HOST--Jaeger agent host
JAEGER_PORT6831Jaeger agent port

Langfuse

Langfuse is configured with the standard OpenTelemetry trace exporter variables, not with Hangar settings. It takes OTLP over HTTP only:

VariableValue
OTEL_EXPORTER_OTLP_TRACES_ENDPOINThttps://cloud.langfuse.com/api/public/otel/v1/traces, or https://<your-host>/api/public/otel/v1/traces
OTEL_EXPORTER_OTLP_TRACES_PROTOCOLhttp/protobuf
OTEL_EXPORTER_OTLP_TRACES_HEADERSAuthorization=Basic%20<base64 of public_key:secret_key>,x-langfuse-ingestion-version=4

Use the TRACES_ variables: OTEL_EXPORTER_OTLP_ENDPOINT also turns on OTLP audit log export, which Langfuse does not accept. See Cookbook 08.

!!! note "Removed in 2.25.0: MCP_LANGFUSE_* and HANGAR_LANGFUSE_*" MCP_LANGFUSE_SCRUB_INPUTS, MCP_LANGFUSE_SCRUB_OUTPUTS, HANGAR_LANGFUSE_SCRUB_INPUTS and HANGAR_LANGFUSE_SCRUB_OUTPUTS, set to any value, refuse the boot with a ConfigurationError naming them. MCP_LANGFUSE_ENABLED, MCP_LANGFUSE_SAMPLE_RATE, HANGAR_LANGFUSE_ENABLED and HANGAR_LANGFUSE_SAMPLE_RATE are named in a langfuse_settings_removed warning, and the boot continues. Nothing read them to any effect after 2.22.0. LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_HOST are the Langfuse SDK's own variables, and Hangar leaves them alone.

Deprecated Variables

HANGAR_LICENSE_KEY is deprecated in v1.3.0. MCP Hangar ignores it and emits a DeprecationWarning when the variable is set. Remove it from deployment manifests; MCP Hangar is MIT-licensed and no longer uses license keys.

License-tier configuration and API fields were removed in v1.3.0. Remove any custom deployment checks for license_tier, LicenseTier, or ApplicationContext.license_tier.

Container Runtime

VariableDefaultDescription
MCP_CONTAINER_RUNTIME--Force container runtime (docker or podman)
MCP_CI_RELAX_VOLUME_PERMS--Relax volume permission checks in CI environments
MCP_CONTAINER_INHERIT_STDERR--Inherit stderr from container processes

Auth

VariableDefaultDescription
MCP_JWT_MAX_TOKEN_LIFETIME--Maximum JWT lifetime in seconds. Wins over oidc.max_token_lifetime_seconds; an issuer's own max_token_lifetime_seconds wins over both