Configuration Reference

All MCP Hangar behavior is controlled through a YAML configuration file and environment variables. The config file defaults to config.yaml in the working directory, overridden by the MCP_CONFIG environment variable. Environment variables take precedence over YAML settings where both exist.

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, remoteMCP Server mode. container and podman normalize to docker.
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)
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----Per-MCP server concurrency limit
capabilitiesdict----Declared capability contract (network, filesystem, environment, tools, resources, enforcement_mode). Hangar enforces these at runtime and flags deviations.

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

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.

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.

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

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.

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_concurrencyint10--Default per-MCP server concurrency limit

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
enabledbool----Enable or disable discovery
refresh_interval_sint----Interval between discovery scans in seconds
auto_registerbool----Automatically register discovered MCP servers
sourceslist[dict][]--Discovery source configurations (see below)
securitydict----Security constraints for discovery
lifecycledict----Lifecycle management for discovered MCP servers

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) or authoritative (adds and removes)
path / patternstrFile path or glob pattern (filesystem source)
watchboolEnable file watching (filesystem source)
socket_pathstrDocker/Podman socket (docker source)
namespaceslist[str]Kubernetes namespaces to scan
label_selectorstrKubernetes label selector
in_clusterboolUse in-cluster Kubernetes config
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
groupstrTarget group for discovered 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_checkbool--Require health check before registration
require_mcp_schemabool--Require valid MCP schema
max_mcp_servers_per_sourceint--Maximum MCP servers per source
max_registration_rateint--Registration rate limit
health_check_timeout_sfloat--Health check timeout in seconds
quarantine_on_failurebool--Quarantine MCP servers that fail health checks

lifecycle sub-section

KeyTypeDefaultDescription
default_ttl_sint--Default TTL for discovered MCP servers
check_interval_sint--Lifecycle check interval in seconds
drain_timeout_sint--Drain timeout before removal

retry

Retry policy for failed operations.

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]--Exception types to retry on
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
enabledbool--Enable event persistence
driverstr--Storage driver: sqlite or memory
pathstr--SQLite database path (sqlite driver only)
allow_memory_fallbackboolfalsePermit 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

observability

Tracing and LLM observability integrations.

tracing sub-section

observability:
  tracing:
    enabled: true
    otlp_endpoint: http://localhost:4317
    service_name: mcp-hangar
    console_export: false
KeyTypeDefaultDescription
enabledbool--Enable 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_exportbool--Export traces to console (development)

langfuse sub-section

observability:
  langfuse:
    enabled: true
    public_key: pk-lf-...
    secret_key: ${LANGFUSE_SECRET_KEY}
    host: https://cloud.langfuse.com
    sample_rate: 1.0
    scrub_inputs: true
    scrub_outputs: true
KeyTypeDefaultDescription
enabledboolfalseEnable Langfuse LLM observability
public_keystr--Langfuse public API key
secret_keystr--Langfuse secret key. Supports env var interpolation: ${LANGFUSE_SECRET_KEY}
hoststr"https://cloud.langfuse.com"Langfuse API host
sample_ratefloat1.0Trace sampling rate (0.0--1.0)
scrub_inputsbooltrueRedact sensitive data from tool inputs. On by default -- set it to false to send raw arguments to Langfuse
scrub_outputsbooltrueRedact sensitive data from tool outputs. On by default

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)
KeyTypeDefaultDescription
enabledbool--Enable authentication
allow_anonymousbool--Allow unauthenticated requests
api_key.enabledbool--Enable API key authentication
api_key.header_namestr--HTTP header name for API key
oidc.enabledbool--Enable OpenID Connect authentication
oidc.issuerstr--OIDC issuer URL
oidc.audiencestr--Expected token audience
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.subject_claimstr--JWT subject claim field
oidc.groups_claimstr--JWT groups claim field
oidc.email_claimstr--JWT email claim field
oidc.tenant_claimstr--JWT tenant claim field
oidc.max_token_lifetime_secondsint3600Maximum accepted JWT lifetime (exp - iat); 0 disables the check
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.enabledbool--Enable Open Policy Agent authorization
opa.urlstr--OPA server URL
opa.policy_pathstr--OPA policy path
opa.timeoutfloat--OPA request timeout in seconds
storagedict--Auth storage configuration (driver, path, host, etc.)
rate_limitdict--Auth-specific rate limiting
role_assignmentslist[dict]--Role assignment rules

config_reload

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

config_reload:
  enabled: true
  use_watchdog: true
  interval_s: 5
KeyTypeDefaultDescription
enabledbool--Enable automatic config file watching
use_watchdogbool--Use watchdog library for file system events
interval_sint--Polling 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
      reset_timeout_s: 60.0
    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
min_healthyint1>= 1Minimum healthy members for group HEALTHY state
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>= 1Total group failures before the circuit opens
circuit_breaker.reset_timeout_sfloat60.0>= 1.0Seconds before the circuit auto-resets
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

Member configuration

Each member entry supports all standard MCP server keys (mode, command, image, endpoint, env, etc.) plus:

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

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', '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.

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"config.yaml"Path to YAML configuration file
MCP_MODE"stdio"Server mode: stdio or http
MCP_HTTP_HOST"0.0.0.0"HTTP bind host
MCP_HTTP_PORT8000HTTP bind port
MCP_LOG_LEVEL"INFO"Logging level (DEBUG, INFO, WARNING, ERROR)
MCP_JSON_LOGS"false"Enable structured JSON logging

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

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_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

VariableDefaultDescription
MCP_LANGFUSE_ENABLED"false"Enable Langfuse LLM observability
LANGFUSE_PUBLIC_KEY--Langfuse public API key
LANGFUSE_SECRET_KEY--Langfuse secret key (sensitive)
LANGFUSE_HOST"https://cloud.langfuse.com"Langfuse API host
MCP_LANGFUSE_SAMPLE_RATE"1.0"Trace sampling rate (0.0--1.0)
MCP_LANGFUSE_SCRUB_INPUTS"false"Redact sensitive tool inputs
MCP_LANGFUSE_SCRUB_OUTPUTS"false"Redact sensitive tool outputs

!!! note "Legacy HANGAR_* prefix" The following legacy variables are supported for backward compatibility but MCP_* is the canonical prefix: HANGAR_LANGFUSE_ENABLED maps to MCP_LANGFUSE_ENABLED, HANGAR_LANGFUSE_SAMPLE_RATE maps to MCP_LANGFUSE_SAMPLE_RATE, HANGAR_LANGFUSE_SCRUB_INPUTS maps to MCP_LANGFUSE_SCRUB_INPUTS, HANGAR_LANGFUSE_SCRUB_OUTPUTS maps to MCP_LANGFUSE_SCRUB_OUTPUTS.

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 token lifetime