From install to a governed deny in 10 minutes
Updated July 22, 2026
From install to a governed deny in 10 minutes
The fastest way to understand an enforcement plane is to watch it change its mind
about a call you already made. So that is what this does. You will stand up the
MCPEgressPolicy plane – the core L7 engine shipped in 1.6.0, plus the
operator-side controller that delivers it end-to-end, landing in v0.14.0
(the release after v0.13.0) – point one policy at one server, and make the
exact same tool call succeed and then get denied – with nothing changing but
the policy.
No new call. No trick. The call is constant; the verdict moves. That gap is the whole product.
Everything here is deterministic: a tool name resolves against globs in a fixed precedence, and an argument scan matches value-regexes. There is no score, no model, no threshold to tune. The answer to “would this call be allowed?” is a function of the policy and the call, and nothing else.
What you need
- A Kubernetes cluster you can
kubectl applyto. - The Hangar core (
1.6.1) running and reachable in the cluster – this is the data plane the L7 rules are enforced on, and whose front door you will call. - A namespace to govern. This walkthrough uses
prodas the example namespace; substitute your own everywhereprodappears below. - An
MCPServeralready registered in that namespace. This walkthrough targets one namedsrv; substitute your own.
1. Install the operator
The operator publishes a rendered install manifest on every tag. Apply the latest:
kubectl apply -f https://github.com/mcp-hangar/mcp-hangar-operator/releases/latest/download/install.yaml
Prefer Helm? The chart installs the MCPEgressPolicy CRD and grants the operator
the RBAC to reconcile it, so the plane works from a plain install:
helm install mcp-hangar-operator \
oci://ghcr.io/mcp-hangar/charts/mcp-hangar-operator --version <version>
The operator needs one flag to enforce L7 (tool-call and argument) rules: it
has to know where the core is, so it can push the compiled policy to the data
plane. Run it with --hangar-url pointed at your core. Without that flag the
operator still applies the L3/L4 network backstop – but the tool-call rules are
never delivered, and the interesting half of this tutorial won’t fire.
2. Opt the namespace into enforcement
Enforcement is opt-in per namespace. Labeling the namespace puts the default-deny floor in place, which is what the backstop builds on:
kubectl label namespace prod mcp-hangar.io/enforce-egress=true
3. Apply a policy in Audit mode – and watch it allow
Start in Audit, the default. Audit observes violations without blocking, so
you can see what a policy would do before it does it. This one allows the
read-family tools on a GitHub upstream:
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
name: gh-only
namespace: prod
spec:
mode: Audit # observe, don't block -- yet
targetRef:
kind: MCPServer
name: srv
defaultAction: Deny # any tool no rule matches is denied
upstreams:
- name: github
match:
host: api.github.com # FQDN -> needs the Cilium flavor
tools:
allow: ["get_*", "list_*"]
Apply it and confirm the operator compiled it and put the backstop in place:
kubectl apply -f gh-only.yaml
kubectl -n prod get mcpegresspolicy gh-only \
-o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'
Compiled=True (Compiled)
BackstopApplied=True (BackstopApplied)
Degraded=False (NotDegraded)
That Degraded=False line assumes a Cilium cluster, where the operator can
compile the api.github.com FQDN into a toFQDNs rule. On a vanilla CNI you would
instead see Degraded=True (FQDNUpstreamsUnenforceable) and the hostname upstream
would fail closed – swap in a literal IP/CIDR upstream if you are following along
without Cilium. The tool-call and argument rules below behave identically either way.
Now make a get_repo call through Hangar’s front door – the same JSON-RPC
tools/call an MCP client would send. Port-forward the core so its front door is on
localhost:8000, then invoke:
kubectl -n prod port-forward svc/mcp-hangar 8000:8000 & # your core's Service
curl -s http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call",
"params":{"name":"get_repo",
"arguments":{"owner":"octocat","repo":"hello-world"}},
"id":1}' \
| jq '.result // .error'
get_repo matches the get_* allow-glob, so it is allowed – the call reaches
api.github.com and the repo payload comes back. Note the request body: you will
replay it byte-for-byte in the next step. (In Audit mode even a call that didn’t
match would go through; the point of this step is the green baseline.)
4. Flip to Enforce – and watch the same call deny
Change two things: turn the mode to Enforce, and move get_repo from the
allow-list into deny. Nothing about the call changes.
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
name: gh-only
namespace: prod
spec:
mode: Enforce # now it blocks
targetRef:
kind: MCPServer
name: srv
defaultAction: Deny
upstreams:
- name: github
match:
host: api.github.com
tools:
allow: ["list_*"]
deny: ["get_repo"] # deny wins over everything
kubectl apply -f gh-only.yaml
Replay the exact same request – byte-for-byte the curl from step 3:
curl -s http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call",
"params":{"name":"get_repo",
"arguments":{"owner":"octocat","repo":"hello-world"}},
"id":1}' \
| jq '.result'
This time the core resolves the tool name against the policy at the invocation
chokepoint, hits the deny rule, and refuses the call before it ever touches the
wire. Instead of the repo payload you get a tool error:
{
"content": [{ "type": "text", "text": "Tool call denied by egress policy" }],
"isError": true
}
The upstream was never contacted. Allowed a moment ago; denied now; nothing changed
but the policy. The caller-facing message stays deliberately generic – the specific
reason (get_repo hit a deny rule) is carried in the audit trail, not leaked back
to the caller.
Tool-name matching runs in a fixed precedence, and deny sits at the top of it:
deny > requireApproval > allow > defaultAction
So deny: ["get_repo"] beats any allow glob it also matches. requireApproval
sits in the middle and fails closed –
a gated call is blocked pending an out-of-band decision, not queued for a live
click.
5. Deny on the arguments, not just the name
The name isn’t the only thing a policy reads. Add an argument scan and a call to an allowed tool still gets denied if its payload carries a secret – because a secret pattern denies the call even when the tool is permitted. Deny always wins:
tools:
allow: ["get_*", "list_*"]
arguments:
deny:
secretPatterns: [aws-keys, jwt, github-tokens]
maxPayloadBytes: 262144
The secret-pattern groups are the same deterministic value-regexes Hangar’s output redactor uses – so what gets masked on the way out is refused on the way in. An oversized or unserializable payload fails closed too.
What you just did
You stood up a real enforcement plane and drove a call across the line in both directions:
- Installed the plane – operator (
v0.14.0) plus core (1.6.1), wired together with--hangar-urlso L7 rules reach the data plane. - Opted a namespace in and let the network backstop – a
NetworkPolicy, or aCiliumNetworkPolicywithtoFQDNsfor hostname upstreams – put the default-deny floor down. That backstop is what makes the L7 policy enforcement and not a suggestion: if a pod could bypass DNS and NetworkPolicy, it could bypass Hangar. - Watched one call flip verdicts. Audit observed it; Enforce with a
denyrule blocked it – the same call, decided differently, by policy alone. - Denied on arguments, proving the verdict reads the payload, not just the tool name.
Every one of those decisions was deterministic and fail-closed: an FQDN with no
Cilium, an unserializable argument, a requireApproval gate – each resolves to a
deny, never to an accidental allow. That is the shape of the whole plane.
Start in Audit on your real servers, read the violations it would have blocked,
then flip to Enforce when the allow-list is honest.
Grounded in the Egress Policy guide and ADR-013 (the enforcement model, and the alternatives it rejected – no TLS interception, no eBPF protocol parsing). Everything is MIT and self-hosted – no SaaS.