Egress Policy (MCPEgressPolicy)

New to this? The L7 MCPEgressPolicy language is the concept behind this page.

Declarative, deny-by-default egress policy for MCP servers: control which upstreams a server may reach, which tool calls it may make, and what happens when the answer is no.

Overview

MCPEgressPolicy is the policy layer above the binary registration switch. Registration (an MCPServer exists), default-deny egress, admission rejection of unregistered pods, and image-pin coupling answer "may this server receive traffic at all?" An egress policy answers the next question: "which upstreams, which tool calls, with which arguments — and what happens on a violation?"

Enforcement has two layers, applied together:

LayerEnforced byGoverns
L3/L4 (network backstop)operator → NetworkPolicy or CiliumNetworkPolicywhich upstream hosts/CIDRs the server's pods can reach
L7 (semantics)core, on connections Hangar proxieswhich tool calls (by name) and which arguments are allowed

The trust boundary is explicit: a policy without the network backstop is a suggestion. If a pod can bypass DNS and NetworkPolicy, it can bypass Hangar. The backstop is what makes the L7 policy enforcement rather than a recommendation. See ADR-013 for the model and the alternatives that were rejected (no transparent TLS interception, no eBPF protocol parsing in v1).

Prerequisites

  • The operator, with the MCPEgressPolicy CRD installed. The MCPEgressPolicy reconciler shipped in operator v0.14.0; v0.13.0 shipped the rest of the enforcement roadmap.
  • The target namespace should be opted into egress enforcement with the label mcp-hangar.io/enforce-egress=true, so the namespace default-deny is in place and the backstop has something to build on. See Governed Namespaces for how the operator keeps that default-deny in place.
  • For FQDN upstreams: a cluster running Cilium. A vanilla NetworkPolicy cannot match on DNS names, so hostname upstreams are only enforceable under the Cilium flavor (see Backstop flavors).
  • For L7 enforcement (tool-call / argument rules): the operator must be run with --hangar-url pointing at the core, so it can deliver the compiled policy to the data plane. Without it, only the L3/L4 backstop is applied.

A complete example

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
  name: gh-only
  namespace: prod
spec:
  mode: Enforce                 # Audit (default) observes; Enforce blocks
  targetRef:
    kind: MCPServer             # or MCPServerGroup
    name: srv
  defaultAction: Deny           # applied to tool names no rule matches
  upstreams:
    - name: github
      match:
        host: api.github.com    # FQDN -> needs the Cilium flavor
      tools:
        allow: ["get_*", "list_*"]
        requireApproval: ["create_*"]
      arguments:
        deny:
          secretPatterns: [aws-keys, jwt]
          maxPayloadBytes: 262144

Applied to a governed namespace on a Cilium cluster, the operator compiles this into a CiliumNetworkPolicy that allows DNS and egress only to api.github.com, and reports:

$ kubectl -n prod get mcpegresspolicy gh-only \
    -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'
Compiled=True (Compiled)
BackstopApplied=True (BackstopApplied)
BackstopEnforceable=True (EnforcerObserved)
L7Delivered=True (Delivered)
Degraded=False (NotDegraded)

kubectl get shows the backstop's enforcement and the L7 delivery as columns:

$ kubectl -n prod get mcpegresspolicies
NAME      MODE      BACKSTOP   L7     TARGET   DEFAULT   AGE
gh-only   Enforce   Enforcing  True   srv      Deny      2m

A pod behind this policy reaches api.github.com (HTTP 200) but not any other host (connection times out), while DNS still resolves.

Governing a group

targetRef.kind: MCPServerGroup applies one policy to every member of a group. The operator resolves the group's member servers and scopes the backstop to all of them (mcp-hangar.io/provider In [members]).

Since operator 0.17.5 the policy follows membership as it changes: a server that starts matching the group's selector is added to the backstop and gets the L7 policy pushed within seconds, one that stops matching is dropped, and a server deleted and recreated under the same name gets its L7 policy back. A group selector edited to match nothing removes the backstop and reports TargetNotFound. Earlier releases resolved members once per reconcile, so a later member ran without the policy until the next resync.

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
  name: web-egress
  namespace: prod
spec:
  mode: Enforce
  targetRef:
    kind: MCPServerGroup
    name: web-servers
  upstreams:
    - name: github
      match: {host: api.github.com}
      tools: {allow: ["get_*", "list_*"]}

Deny everything

With defaultAction: Deny (the default) and no upstreams, the policy denies all egress except the always-permitted DNS/backstop paths — a locked-down server that can resolve names but reach no upstream:

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
  name: lockdown
  namespace: prod
spec:
  mode: Enforce
  targetRef: {kind: MCPServer, name: srv}
  # no upstreams -> nothing is allowed out

Spec reference

Top level

FieldTypeDefaultDescription
modeAudit | EnforceAuditAudit observes violations; Enforce blocks. Audit-default gives a Gatekeeper-style adoption path.
targetRef.kindMCPServer | MCPServerGroup—What the policy attaches to. A group applies the policy to every member server. Immutable since operator 0.17.6 (see below).
targetRef.namestring—Referent name, resolved in the policy's namespace. Immutable since operator 0.17.6 (see below).
defaultActionDeny | AllowDenyOutcome for a tool name that no upstreams[].tools rule matches.
upstreams[]list—The allow-list. With defaultAction: Deny, an empty list denies everything except the DNS/backstop paths.
networkBackstopobjectgenerate/AutoControls the generated L3/L4 backstop (below).

targetRef cannot be changed. Since operator 0.17.6 the CRD carries a CEL rule that refuses any update to spec.targetRef, whether or not the validating webhook runs (it is off by default). To govern a different server or group, delete the policy and create one for the new target. Earlier releases accepted the edit. mode, defaultAction and the rest of the spec can still be edited in place.

upstreams[]

FieldTypeDescription
namestringRule name, unique within the policy (enforced by a CEL rule).
match.hoststringUpstream host: an FQDN (needs Cilium), or a literal IP/CIDR (works under any CNI).
match.toolSchemaDigestRefstringReferences an existing per-tenant tool-schema pin.
match.imageDigestrequired | inheritedHow the target's image pin gates this upstream.
match.issuerslistRestricts which token issuers may be brokered to this upstream.
tools.allow / tools.deny / tools.requireApprovallist of globsTool-name globs. Precedence: deny > requireApproval > allow > defaultAction.
headers.allow / headers.deny / headers.requireApprovallistMcp-Param-* header selectors; see the policy language recipe.
arguments.deny.secretPatternslistNamed secret-pattern groups to reject (below).
arguments.deny.maxPayloadBytesintegerReject tool-call argument payloads larger than this.

networkBackstop

FieldTypeDefaultDescription
generatebooltrueEmit the L3/L4 backstop. false removes it (the policy then relies on the namespace default-deny alone); the L7 rules are still delivered to core and reported on L7Delivered.
flavorAuto | Cilium | VanillaAutoBackstop implementation.

Backstop flavors

The network backstop is what guarantees the data plane cannot be bypassed. The operator picks or is told a flavor:

  • Vanilla — a standard NetworkPolicy: default-deny egress on the target's pods, allowing DNS plus any upstream whose host is a literal IP/CIDR. A vanilla NetworkPolicy cannot match on FQDNs, so hostname upstreams are failed closed (denied, never opened to "any destination") and surfaced as Degraded/FQDNUpstreamsUnenforceable.
  • Cilium — a CiliumNetworkPolicy with toFQDNs, which does enforce hostname allow-lists. The DNS rule carries an L7 DNS-proxy rule so Cilium learns the resolved IPs and admits only traffic to the allow-listed names. CIDR upstreams become toCIDR.
  • Auto (default) — Cilium if the CiliumNetworkPolicy CRD is installed and a cilium agent DaemonSet runs, otherwise Vanilla.

If Cilium is requested on a cluster without the CRD, the operator applies the Vanilla floor and reports Degraded/CiliumUnavailable; if the CRD is installed but no cilium agent is observed, it applies the Vanilla floor and reports Degraded/CiliumAgentNotObserved. Either way it fails closed, never open.

Since operator 0.17.7 the CRD alone is not taken as Cilium. The Cilium CRDs survive cilium uninstall unless purged, and on such a cluster earlier releases picked the Cilium flavor, wrote a CiliumNetworkPolicy nobody reads, deleted the NetworkPolicy the running CNI enforces and reported Enforcing. Now Auto moves such a policy to Vanilla on its next reconcile and deletes the stale CiliumNetworkPolicy. Hostname upstreams, which only the Cilium flavor can allow, are then denied and reported; they were never enforced there. Recognizing the agent needs the DaemonSet read grant (chart 0.12.19 and later): without it a real Cilium cluster cannot be confirmed and Auto falls back to Vanilla too, so upgrade the chart with the operator.

L7 semantics

The L7 half runs in the core, on the connections Hangar already proxies. It is deterministic: no ML, no heuristics to tune (full DLP and ML classification are explicit non-goals).

Tool-call matching resolves a tool name by glob, in precedence order:

  1. deny — reject.
  2. requireApproval — the call is routed into the interactive approval gate (core 2.11.0): a typed pending approval is created and delivered on the configured channel, resolution requires approval:resolve, and the decision is revalidated at dispatch — so an approval granted while a policy was in force is not usable after that policy changes, and deny still wins if the policy hardens during the hold. With no approvals: block the call is held on the default event_stream channel. Only with the gate turned off (approvals: {enabled: false}) does the verdict fail closed -- refused as EgressPolicyApprovalRequiredError, exactly as before 2.11.0 (see Limitations).
  3. allow — permit.
  4. otherwise — the policy's defaultAction.

Globs are case-sensitive for determinism (get_* does not match GET_user).

Argument scanning rejects a tool call whose arguments contain a configured secret pattern or exceed maxPayloadBytes. A secret or oversized payload denies the call even when the tool itself is allowed — deny always wins. Arguments that cannot be serialized for inspection also fail closed.

How the L7 policy is delivered. The operator compiles the policy's per-upstream tools/arguments rules into a single per-server policy — the union of the upstreams' allow/deny/require-approval globs and secret-pattern groups, and the most restrictive (smallest) maxPayloadBytes — and pushes it to the core (requires --hangar-url). The core enforces it at the tool-invocation chokepoint: a denied call raises before it reaches the upstream; an approval-gated call is blocked pending approval. Deleting the policy clears it from the core. Whether every target server accepted the push is reported on the L7Delivered condition (see Status conditions).

The L7 half does not depend on the backstop. Since operator 0.17.5 a policy with networkBackstop.generate: false still has its rules pushed; earlier releases stopped at the backstop decision and delivered nothing. A generate: false policy in Enforce mode written on the assumption that only its backstop mattered will start blocking, or routing to approval, the tool calls its rules name. A generate: false policy whose target does not exist reports Compiled=False / TargetNotFound and Degraded=True.

Secret-pattern groups

secretPatterns names groups; each maps to deterministic value-regexes shared with Hangar's output redactor, so what the redactor masks on the way out is what a policy refuses on the way in (pem-blocks is the exception: the redactor carries no PEM pattern):

GroupDetects
aws-keysAWS access key IDs (AKIA…)
jwtJSON Web Tokens (eyJ….…)
pem-blocksPEM private-key blocks
github-tokensGitHub PATs / OAuth / server / refresh tokens
stripe-keysStripe live/test/restricted keys
slack-tokensSlack tokens (xox…)
google-api-keysGoogle API keys (AIza…)
bearer-tokensBearer … credentials
npm-tokens, pypi-tokensnpm / PyPI tokens

An unknown group name refuses the whole policy: the core answers the push with 400 invalid_l7_policy, naming the unknown group and listing the known ones. CRD validation does not catch it -- the field is a plain string list -- so a misspelt github-token passes kubectl apply and is refused at delivery.

Surviving a gateway restart

The operator delivers the L7 policy when it reconciles the MCPEgressPolicy. Since operator 0.17.4 it also delivers every policy again when a gateway pod becomes Ready -- a new pod, a container restarted in place, or every Ready gateway pod when the operator itself starts. It finds gateway pods with --hangar-gateway-selector (default app.kubernetes.io/name=mcp-hangar, the mcp-hangar chart's label; empty disables it). A selector that matches no pod makes this a no-op, and the operator logs an error-level line saying so. With more than one gateway replica and no shared backend, the push goes through the --hangar-url Service and reaches whichever replica it routes to, not necessarily the one that restarted.

Where the re-delivery does not reach -- an older operator, a selector that matches nothing, a replica the Service did not route to -- whatever the gateway does not keep across a restart is not enforced from the restart until the next reconcile, which can be hours. The L3/L4 backstop is not affected; it lives in the cluster, not in the gateway.

Whether the gateway keeps the policy depends only on how it stores its fleet (core 2.22.1 and later):

DeploymentAfter a gateway restartpersisted in the push response
No persistence backend -- the chart default, persistence.backend: ""lost until the operator delivers it againfalse; the gateway logs l7_policy_not_persisted at warning
sqlite on the chart's default emptyDirkept across a container restart, lost when the pod is replaced -- that is, on every rollouttrue -- the gateway cannot see what its volume is
sqlite with persistence.sqlite.persistentVolume.enabled: truekepttrue
postgresqlkept; each replica reads it back when it startstrue
MCP_PERSISTENCE_ENABLED=true with MCP_AUTO_RECOVER=falselost: written, never read backfalse, reason auto_recover_off

This holds for servers declared in config.yaml and servers registered over the REST API alike. Clearing a policy is kept the same way, so a restart does not bring back a policy the operator deleted. Core releases before 2.22.1 lost the policy of every server config.yaml declares on every restart, whatever the backend (mcp-hangar#1306).

Two failures at start are not covered by the table:

  • A stored policy that no longer parses fails closed. The server denies every tool until the operator delivers its policy again, and the gateway logs l7_policy_unreadable_denying_all at error.
  • A database that cannot be read at start fails open. The gateway starts anyway, so that the servers config.yaml declares keep serving, logs the failure at error, and serves them without their stored policies until the operator delivers them again.

persisted answers for the gateway's configuration, not for its storage: a SQLite file on an emptyDir reports true and is still gone after a rollout. To keep the policy, choose a durable backend in the chart values -- PostgreSQL, which multiple replicas need anyway, or SQLite on a persistent volume:

persistence:
  backend: sqlite
  sqlite:
    persistentVolume:
      enabled: true

To find a deployment that does not keep it, search the gateway log for l7_policy_not_persisted. It is logged on every push, so a deployment that drops the policy on restart shows it on every reconcile.

Status conditions

ConditionMeaning
CompiledThe policy was structurally compiled.
BackstopAppliedThe L3/L4 backstop is in place (False with BackstopGenerationDisabled when generate: false, and False with RemoteTargetHasNoPods when the target is a mode: remote server, operator 0.17.28).
BackstopEnforceableWhether anything in the cluster enforces the written backstop: True / EnforcerObserved when the operator finds a policy-enforcing API or a known CNI agent, False / NoEnforcerObserved when it finds neither (status.backstopEnforcement: Unenforced; the Backstop column of kubectl get), Unknown / EnforcementUnverified when it could not tell, for example because it may not list DaemonSets (status.backstopEnforcement: Unverified).
L7DeliveredWhether core took the compiled L7 policy (operator 0.17.5). True / Delivered once every target server accepted the push; True / DeliveredNotPersisted when core took it but has no persistence backend (see Surviving a gateway restart); False / CoreAuthRejected, CoreUnreachable or PushFailed, naming the server whose push failed; Unknown / CoreIntegrationOff when the operator runs without --hangar-url. Shown as the L7 column of kubectl get mcpegresspolicies.
DegradedAn at-risk state: FQDNUpstreamsUnenforceable (FQDN upstreams under the Vanilla flavor), CiliumUnavailable (Cilium requested, CRD absent), CiliumAgentNotObserved (Cilium requested, CRD present, no cilium agent observed; operator 0.17.7 and later), TargetNotFound, EnforcementNotObserved (nothing observed to enforce the backstop), or L7PushFailed (L7Delivered=False).

Compiled and BackstopApplied say nothing about core. Before operator 0.17.5 a policy whose L7 push core refused -- an API key without policy:write, a core that was down, a payload core rejected -- read Compiled=True and Degraded=False, with a Warning Event as the only trace. It now reads L7Delivered=False and Degraded=True / L7PushFailed, so an alert on Degraded may fire on a policy that has been undelivered since it was created. Fix the key's permissions or core's reachability; the next reconcile clears it.

A remote target has no backstop. A mode: remote server runs no pod; core dials its address, so its traffic leaves from the gateway pod, and a NetworkPolicy selecting the server's pods would select nothing. Before operator 0.17.28 the policy reported BackstopApplied=True and backstopEnforcement: Enforcing over exactly that. Now it writes no backstop (and removes one left behind), reports BackstopApplied=False with reason RemoteTargetHasNoPods and backstopEnforcement: NotApplicable, and still delivers the L7 rules to core. For a remote target they are the only enforcement; the network path is controlled by the gateway pod's own egress policy. A group reads NotApplicable only when every member is remote.

One enforcement probe for every NetworkPolicy the operator writes. The probe behind BackstopEnforceable looks for a policy-enforcing API (Calico, Antrea, AWS VPC CNI, Kube-OVN, OVN-Kubernetes) and, failing that, for a known CNI agent DaemonSet (azure-npm, calico-node, canal, cilium, kube-ovn-cni, kube-router, ovnkube-node, weave-net); it re-asks at most once every five minutes. Cilium is recognized by its agent only: before operator 0.17.7 the CiliumNetworkPolicy CRD alone counted, and it outlives an uninstall. Since operator 0.17.6 the same verdict also drives the MCPServer NetworkPolicyApplied condition and the namespace default-deny Warning (see Kubernetes: NetworkPolicy enforcement status). The agent check lists DaemonSets, which the Helm chart grants (apps/daemonsets get, list) from chart 0.12.19; the operator repository's own RBAC manifests already carry it. With an older chart the list is refused, so every cluster that serves none of those APIs reads Unknown -- one with no enforcer as well as one whose enforcer ships no CRD -- and the False / unenforced states and their Warnings never fire. If your CNI enforces NetworkPolicy and the probe does not recognize it, start the operator with --networkpolicy-enforcement=enforced (chart value operator.networkPolicyEnforcement, chart 0.12.21 and later); the flag now applies to all three writers. It changes only what status reports: the policies are written either way. Since operator 0.17.7 it no longer stands in for the agent check: the backstop flavor is chosen from the DaemonSets actually running, whatever the flag says.

Limitations and notes

  • L7 needs core integration. The tool-call / argument rules are enforced only when the operator runs with --hangar-url; otherwise a policy applies its L3/L4 backstop, its L7 rules are not delivered, and it reports L7Delivered=Unknown / CoreIntegrationOff.
  • The CR does not report whether the gateway still holds the L7 policy. L7Delivered=True means core accepted the last push, not that the gateway kept it. A gateway without durable storage drops it on restart until it is delivered again; see Surviving a gateway restart. To see what a gateway holds right now, GET /api/mcp_servers/{id}/l7_policy.
  • FQDN enforcement requires Cilium. Under other CNIs, list upstreams as CIDRs, or accept that hostname upstreams are denied (fail closed) and surfaced via Degraded.
  • L7 rules are merged per server. Because the core enforces one policy per server (not per upstream connection), a policy's upstream tools/arguments rules are flattened together (see above). Scope host-specific tool rules with separate policies if you need them kept apart.
  • requireApproval needs the approval gate to be interactive — since core 2.11.0 a gated call blocks on the approval gate (typed pending approval, approval:resolve chokepoint, dispatch-time revalidation), delivered on the default event_stream channel when nothing else is configured; on a deployment with the gate turned off (approvals: {enabled: false}) it fails closed, as it always did. Audit mode records the would-be verdict and never asks a human.
  • Enforce governs new connections only — it does not cut established ones. This is normal NetworkPolicy behaviour: the CNI keeps established conntrack entries, so a server holding a TCP session opened before the policy landed keeps talking through it until that connection closes. Measured live (kind + Calico): data sent after a restrictive policy applied still flowed through the pre-existing session. Flipping Audit → Enforce and seeing BackstopApplied: True therefore means "no new disallowed connections", not "all disallowed traffic stopped now". To guarantee existing sessions are cut, roll the server's pods after switching to Enforce (kubectl rollout restart).
  • stdio servers are out of scope of the L3/L4 backstop. An in-pod stdio MCP server generates no network traffic of its own, so an MCPEgressPolicy backstop has nothing to act on — your transport choice decides whether the network half of this feature applies to you at all. The L7 half (tool/argument rules) still applies, because it is enforced in the data plane Hangar operates, not on the wire.
  • NodeLocal DNSCache on Cilium needs Cilium's setup and one operator flag. In the usual NodeLocal topology, kubelet points at 169.254.20.10 and the cache runs on the host network. Cilium's DNS proxy then never sees a lookup, the toFQDNs allow-list stays empty, and every hostname upstream is denied. This fails closed: a name outside the allow-list stays blocked too. From operator 0.17.20 the policy reports it as Degraded with reason FQDNLookupsUnobserved, naming the cache and the denied upstreams. The supported setup, which the operator's reachability e2e runs on every PR:
    • Run the cache as an ordinary pod behind a CiliumLocalRedirectPolicy: Cilium's own examples/kubernetes-local-redirect/node-local-dns.yaml and node-local-dns-lrp.yaml, with localRedirectPolicies.enabled=true in Cilium. Pods keep the kube-dns service IP, and Cilium redirects each lookup to the cache on the same node.
    • Start the operator with --dns-egress-selectors=kube-system/k8s-app=node-local-dns (chart value operator.dnsEgressSelectors). After the redirect, a lookup's destination is a cache pod, and the DNS proxy inspects only lookups to the pods the DNS rule selects. Without the flag the allow-listed name is denied, and the policy reports FQDNLookupsUnobserved with the flag to add.
    • --dns-egress-cidrs is not read on the Cilium path.
  • The Cilium backstop keeps DNS inside the cluster. Before operator 0.17.20, a pod governed by the Cilium flavor got answers from a resolver on the internet: a DNS channel past the allow-list (mcp-hangar-operator#304). The CiliumNetworkPolicy now carries an egressDeny for port 53 to world. As a result, an upstream given as a CIDR outside the cluster is denied on port 53 under the Cilium flavor. The Vanilla flavor was not affected; its DNS rule was already scoped.

See also