Architecture
New to this? What is a policy enforcement plane? is the concept behind this page.
Overview
MCP Hangar is the Kubernetes-native policy enforcement plane for MCP. Every tool call an agent makes runs through one deterministic allow/deny path before it reaches an upstream MCP server. Enforcement is rule-based and deterministic — there are no anomaly scores and no baseline to train. MCP Hangar is MIT-licensed and self-hosted; there is no SaaS, managed, or enterprise tier.
Enforcement is delivered across two distinct planes:
- The per-request enforcement plane (core,
mcp-hangaron PyPI) — an ordered pipeline of controls that fires on every synchronoustools/call, ending with an L7 egress-policy gate immediately before the wire. - The deploy-time admission plane (the operator,
mcp-hangar-operator) — Kubernetes admission webhooks and network policy that decide, at pod/CR admission time, whether a workload may exist and where it may talk. This is a different plane and a different temporal moment from the per-request path.
A third capability — a governed async task relay with a mid-flight consent gate — shipped in 2.0 (see below).
Beyond enforcement, MCP Hangar also manages MCP servers with explicit lifecycle, health monitoring, and automatic cleanup — the machinery the enforcement path runs on top of.
MCP Hangar is organized as a monorepo plus the operator:
| Package | Description | Location |
|---|---|---|
| Core package | Python library (PyPI: mcp-hangar) — enforcement pipeline, auth, compliance, approvals, integrations, persistence | src/mcp_hangar/ |
| Operator | Kubernetes operator (Go) — admission webhooks, CRDs, network policy, MCPEgressPolicy controller | mcp-hangar-operator (separate repo) |
Since v1.3.0, the core is a single MIT-licensed package. The former enterprise/
package was absorbed into src/mcp_hangar/; features are no longer split by
license tier or gated by license keys.
Key concepts:
- Enforcement pipeline -- Ordered per-request controls on every tool call
- Admission plane -- Deploy-time pod registration, image-digest pinning, default-deny egress (ADR-013)
- Egress L7 policy -- Tool/argument-level allow/deny, the last gate before upstream
- MCP servers -- Subprocesses or containers exposing tools via JSON-RPC
- State machine -- COLD -> INITIALIZING -> READY -> DEGRADED -> DEAD
- Health monitoring -- Failure detection with circuit breaker
- GC -- Automatic shutdown of idle MCP servers
- CQRS / Event Sourcing -- Command/query separation; append-only event store (Event Sourcing)
- Digest Pinning -- SHA-256 tool-schema verification (ADR-004)
- Interceptor Framework -- Experimental pre/post hooks, off by default (ADR-005)
The per-request enforcement pipeline (core, v1.6.0)
Every governed synchronous tools/call funnels through a single chokepoint (the
batch executor; the front-door flat tools/call handler delegates to it to reuse
the exact same path). The controls fire in a definite order — this is a
pipeline, not a flat seam — and the egress L7 gate sits at the very end, inside
tool invocation, just before any bytes leave for the upstream:
| # | Control | Notes | Status |
|---|---|---|---|
| 1 | Identity / auth middleware | Authenticates the request and binds the tenant (ASGI middleware, upstream of the pipeline) | v1.6.0 |
| 2 | Tool-access authz | Tenant/member scope check — is this caller allowed this tool? | v1.6.0 |
| 3 | Tool-withdrawal check | Per-tenant withdrawal of a previously exposed tool | v1.6.0 |
| 4 | Tool-schema digest-pin verify | SHA-256 pin over the tool's canonical schema; audit/warn/block, fails closed under block | v1.6.0 (opt-in) |
| 5 | Circuit-breaker / health | Rejects calls to unhealthy servers/groups | v1.6.0 |
| 6 | Interceptor validators | Empty/no-op unless explicitly configured | v1.6.0 (experimental, off by default) |
| 7 | Approval gate (HITL) | A tool matched by tools.approval_list is held for a human decision (approval_timeout_seconds, default 300); denial or expiry refuses the call. Fails closed | 2.1.0 (reachable) |
| 8 | Concurrency / backpressure | Global + per-server semaphores | v1.6.0 |
| 9 | Interceptor mutators (request) | Argument rewriting; no-op unless configured | v1.6.0 (experimental, off) |
| 10 | Egress L7 policy | The last gate before the wire. Tool-name globs + secret-pattern + payload-size scan; DENY or REQUIRE_APPROVAL. Evaluated inside tool invocation, before cold-start and before any upstream I/O | v1.6.0 |
| → | Upstream MCP server | Hangar-originated connection | |
| 11 | Interceptor mutators (response) + response truncation | Response-side transforms; oversized responses truncated | v1.6.0 |
Cross-cutting: audit & observability. Every governed step emits both domain
events (on the event bus, persisted to the event store) and OTel spans
(policy.check_access, approval_gate.check, concurrency.acquire,
command.send.InvokeToolCommand, and the per-tool call span). Telemetry follows
the OTel GenAI/MCP semantic conventions (gen_ai.tool.name, mcp.method.name,
gen_ai.operation.name). See Observability.
Notes and honest caveats:
- Interceptors are experimental and off by default. The validator/mutator
pipelines register no interceptors out of the box; the public
interceptors/listendpoints are conformance-shaped no-ops. Do not treat interceptors as a live enforcement control. See Interceptor Framework. - The HITL gate (step 7) prompts a human and waits — from 2.1.0 only. The
control existed and was unit-tested from v1.6.0, but on every shipped build no
config key could put a tool behind it, the gate service was never constructed,
and
GET /api/approvalsanswered500while the gated call executed immediately (#678). It is wired on all construction paths from 2.1.0, and a config that demands it without a gate service now refuses the boot rather than starting ungated. Seeapproval_list. - The sync L7
requireApprovaloutcome is a different control and still fails closed — it blocks the call outright. It is not an approval queue and does not enqueue one. Nor is the v2 relay consent gate (below) a human prompt: it fails closed on a decision the client volunteers by drivingtasks/update. - Deeper detail: Front-Door Mode & Per-Tenant Tool Governance, Egress Policy, Authentication & RBAC.
The deploy-time admission plane (operator)
The operator enforces at Kubernetes pod/CR admission — a separate plane from
the per-request pipeline. It applies only in namespaces labeled
mcp-hangar.io/enforce-egress=true (opt-in, governed namespaces). The CRD API
version is v1alpha2. See ADR-013.
- Pod-registration webhook — denies a pod that claims to be an MCP server
unless a registered
MCPServerCR exists (validating, fail-closed). - CR validation — validates
MCPServer/ policy custom resources on create/update. - Image-digest pinning — requires
image@sha256:...; modes off/warn/block. (Distinct from the request-path tool-schema digest pin — different digest, different plane.) - Default-deny egress + L3/L4 network backstop — restricts which hosts a server pod may reach. FQDN upstreams require the Cilium flavor.
- MCPEgressPolicy controller — compiles an
MCPEgressPolicyCR and pushes the L7 policy down to the core engine, where it is enforced at the tool-invocation chokepoint (control #10 above).
End-to-end L7 egress enforcement is shipped. The core L7 engine and REST
intake arrived in 1.6.0; the operator's MCPEgressPolicy controller compiles a
policy and delivers it to core, forwarding the policy mode to the compiled L7
payload, so a declared policy is enforced at the invocation chokepoint.
Which versions carry that, and the chart and image digests, are in the released-artifacts matrix -- regenerated from the registry, rather than restated here where it would go quietly out of date.
Governed async task relay + consent (shipped in 2.0)
A governed async-task capability shipped in 2.0.0; a plain
pip install mcp-hangar gets it. It is not in the closed 1.6.x line.
See ADR-014 and
Governed Tasks.
- Relay with governance, not an executor. Hangar never creates or runs tasks;
it captures an upstream-returned task handle and governs it. There is no
scheduler, job runner, or result store. Every relayed task is governed at relay
time (
GovernedTaskStore+ aTaskCreatedaudit event). This supersedes the earlier relay-only stance (ADR-008) in part. - Serving handlers: the SEP-2663 set —
tasks/get(outcome inlined, with pinned-digest re-verification before any payload is handed over),tasks/update,tasks/cancel.tasks/resultandtasks/listare removed by the SEP and answer-32601. - Mid-flight consent gate — an upstream
input_requiredsurfaces itsinputRequeststo the client, which answers by drivingtasks/update. That update is the consent: gated before the answer reaches the upstream, consumed only on a confirmed relay, recorded asTaskConsentDecided. Hangar no longer elicits the client itself; that flow belonged to the 2025-11-25 wire. - Refused with the code the SEP specifies:
-32601on a legacy connection,-32021(withrequiredCapabilities) for a modern client that did not declare the extension,-32020for a missing or contradictoryMcp-Name,-32602for an unknown or unowned task. - Gated by the
relay_tasks_enabledkill-switch, which defaults to true again: ADR-015 Decision 5 set the condition for reactivating it as the SEP-2663 shapes actually being served, and they are -- from vendored models rather than the SDK's frozen SEP-1686 types.
Everything in this section is in the stable 2.0.0 release. It is absent from
the 1.6.x line, which rejects an upstream task handle with
TaskRelayNotSupported.
Layer Structure (DDD + CQRS)
The Python core follows Domain-Driven Design with strict layer separation:
src/mcp_hangar/
+-- domain/ Core business logic (NO external dependencies)
| +-- model/ Aggregates: MCP Server, McpServerGroup
| +-- events.py Domain events
| +-- exceptions.py Exception hierarchy
| +-- value_objects/ McpServerId, McpServerMode, IdleTTL, ToolDigest, etc.
| +-- policies/ Egress L7 policy engine (deterministic evaluate())
| +-- services/ Digest validator, tool-access resolver, task consent
| +-- contracts/ Interfaces (IMetricsPublisher, IMcpServerRuntime)
| +-- security/ Rate limiting, input validation
|
+-- application/ Use cases and orchestration
| +-- commands/ Command handlers (CQRS write side)
| +-- queries/ Query handlers (CQRS read side)
| +-- sagas/ Long-running processes (recovery, failover)
| +-- event_handlers/ React to domain events
| +-- services/ Application services (TracedMcpServerService)
| +-- ports/ Port interfaces (ObservabilityPort)
|
+-- infrastructure/ External concerns (implements domain contracts)
| +-- discovery/ Docker, K8s, filesystem, entrypoint sources
| +-- identity/ Identity middleware (tenant binding)
| +-- persistence/ Repositories, Event Store (SQLite, in-memory)
| +-- registry/ Registry client
| +-- event_bus.py In-process event bus
| +-- command_bus.py CQRS command dispatcher
| +-- query_bus.py CQRS query dispatcher
|
+-- server/ Protocol and transport layer
+-- api/ REST API (Starlette routes)
| +-- ws/ WebSocket endpoint (events)
+-- tools/ MCP tool implementations + batch executor (the chokepoint)
+-- bootstrap/ DI composition root
+-- cli/ CLI (typer-based)
Layer dependencies flow inward only: Domain knows nothing about infrastructure. Infrastructure implements domain contracts. Server depends on all layers.
System Architecture
flowchart TD
rest["REST API (Starlette)<br/>/api/mcp_servers · /api/groups · /api/discovery · /api/ws/*"]
mcp["MCP Protocol Layer<br/>FastMCP server (stdio or HTTP transport)<br/>hangar_* MCP tools · tools/call enforcement chokepoint"]
cqrs["CQRS + Event Bus<br/>CommandBus → Handlers · QueryBus → Handlers · EventBus"]
server["McpServer<br/>Aggregate"]
group["McpServerGroup<br/>Aggregate"]
sagas["Sagas"]
infra["Infrastructure<br/>StdioClient · DockerLauncher · EventStore · HealthTracker<br/>Discovery Sources · Registry Client · Log Buffers"]
rest --> mcp
mcp --> cqrs
cqrs --> server
cqrs --> group
cqrs --> sagas
server --> infra
group --> infra
State Machine
stateDiagram-v2
[*] --> COLD
COLD --> INITIALIZING: ensure_ready()
INITIALIZING --> READY: success
INITIALIZING --> DEAD: failure
READY --> DEGRADED: failures ≥ threshold
DEGRADED --> INITIALIZING: reinitialize
DEAD --> INITIALIZING: retry < max
Valid transitions:
| From | To |
|---|---|
| COLD | INITIALIZING |
| INITIALIZING | READY, DEAD, DEGRADED |
| READY | COLD, DEAD, DEGRADED |
| DEGRADED | INITIALIZING, COLD |
| DEAD | INITIALIZING, DEGRADED |
There is no direct DEGRADED -> READY transition. Degraded MCP servers must reinitialize.
CQRS Pattern
Commands modify state, queries read state. They never mix.
- Commands:
StartMcpServerCommand,CreateMcpServerCommand,CreateGroupCommand,SetEgressPolicyCommand, etc. - Queries:
ListMcpServersQuery,GetMcpServerQuery,GetSystemMetricsQuery, etc. - Events:
McpServerStarted,ToolInvocationCompleted,HealthCheckFailed,DigestMismatchEvent, etc.
All state changes emit domain events via AggregateRoot._record_event(). Events
are persisted to the Event Store for auditing and can be replayed. See
Event Sourcing.
Threading
Lock Hierarchy
Acquire in order to avoid deadlocks (see lock_hierarchy.py, in the shared kernel):
PROVIDER(10) < PROVIDER_GROUP(11) < EVENT_BUS(20) < EVENT_STORE(30) < SAGA_MANAGER(40) < STDIO_CLIENT(50)
TrackedLock enforces this ordering at runtime.
Threads
| Thread | Purpose |
|---|---|
| Main | FastMCP server, tool calls |
| Reader (per MCP server) | Read stdout, dispatch responses |
| Stderr Reader (per MCP server) | Capture stderr into log buffer |
| GC Worker | Idle MCP server cleanup |
| Health Worker | Periodic health checks |
| Metrics Snapshot Worker | Periodic metrics history capture |
Safe I/O Pattern
# Copy reference under lock, I/O outside lock
with lock:
if state == READY:
client = conn.client
response = client.call(...) # Outside lock
Error Handling
| Category | Strategy |
|---|---|
| Transient (timeout) | Retry with backoff |
| Permanent (not found) | Fail fast, mark DEAD |
| MCP Server (app error) | Propagate, track metrics |
Circuit Breaker
MCP Server groups use a circuit breaker to isolate failing members:
- CLOSED -- Normal operation, failures tracked
- OPEN -- Requests rejected, backoff timer active
- HALF_OPEN -- Single test request allowed to probe recovery
Performance
Recommended TTL:
- Subprocess: 180-300s
- Container: 300-600s
- Remote: 600+ (connection pooling)