Kubernetes Quick Start: from an empty kind cluster to a governed deny
Start with nothing but Docker and end with a tool call that core refuses
because of a Kubernetes policy. On the way you install core and the operator
from the published charts, run one MCP server in a pod, call one of its tools
through core with an API key, and then deny that tool with an
MCPEgressPolicy. About ten minutes on a laptop.
Every command below runs as written, in order, in one shell. A nightly job
does exactly that on a fresh kind cluster against the published charts
(scripts/run_kind_walkthrough.py), and fails unless the refusal in step 6
comes back.
What you need
- Docker, and kind 0.30 or later
kubectl, Helm 3 or 4,curlandjq- Port 8080 free on your machine
1. Create a cluster
kind create cluster --name hangar
kubectl -n kube-system get configmap kubeadm-config \
-o jsonpath='{.data.ClusterConfiguration}' | grep podSubnet
The second command prints kind's pod network, podSubnet: 10.244.0.0/16. Core
needs that range in a moment: the operator registers each server at its pod's
address, and core refuses an endpoint on a private address it was not told to
trust.
2. Install core and the operator
One values file serves both charts; each chart reads only its own keys. Save it
as values.yaml:
# mcp-hangar (core)
auth:
enabled: true
allow_anonymous: false
api_key:
enabled: true
persistence:
backend: sqlite # the first API key needs a durable store
ssrf:
trustedNetworks: ["10.244.0.0/16"] # kind's pod network
config:
trustedHosts: # the operator calls core by its Service name
- mcp-hangar.mcp-hangar.svc.cluster.local
- localhost
- 127.0.0.1
# mcp-hangar-operator
hangar:
url: http://mcp-hangar.mcp-hangar.svc.cluster.local:8080
existingSecret: hangar-operator-key
Install core first. It refuses to serve HTTP on a pod address without
authentication, so auth is on and every call needs a key:
helm install mcp-hangar oci://ghcr.io/mcp-hangar/charts/mcp-hangar \
--namespace mcp-hangar --create-namespace -f values.yaml --wait --timeout 5m
Nothing can mint the first key over HTTP, because /api/auth/** already wants
an administrator. mcp-hangar auth bootstrap-admin, run inside the core pod,
grants one and prints its key once:
HANGAR_API_KEY=$(kubectl -n mcp-hangar exec deploy/mcp-hangar -- \
mcp-hangar auth bootstrap-admin --config /etc/mcp-hangar/config.yaml \
--principal service:walkthrough --show-key | awk '/api key/ {print $NF}')
kubectl -n mcp-hangar create secret generic hangar-operator-key \
--from-literal=api-key="$HANGAR_API_KEY"
The key is stored hashed, so $HANGAR_API_KEY in this shell is the only copy.
The operator uses the same key here to keep the page short; give it a key of
its own anywhere else.
Then the operator, which reads hangar.url and the Secret from the same file:
helm install mcp-hangar-operator oci://ghcr.io/mcp-hangar/charts/mcp-hangar-operator \
--namespace mcp-hangar -f values.yaml --wait --timeout 5m
The operator chart 0.12.52 and later defaults to operator 0.17.36, the first release that registers container servers in core itself.
3. Run an MCP server in a pod
The server is a few lines of Python with two tools, add and read_env. It
runs in core's own public image, which ships the MCP SDK, so you build nothing.
Save it as server.yaml:
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
name: notes
namespace: mcp-hangar
spec:
mode: container
image: ghcr.io/mcp-hangar/mcp-hangar:2.36.1@sha256:71c6a52a8880e910f21ed05ead8178343aee721dd1ac544ebf55349d7aac213f
env:
- name: DEMO_TOKEN
value: not-a-real-secret
command: ["python3", "-c"]
args:
- |
import os
from mcp.server.mcpserver import MCPServer
mcp = MCPServer(name="notes")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@mcp.tool()
def read_env(name: str) -> str:
"""Read one of the server's environment variables."""
return os.environ.get(name, "")
mcp.run("streamable-http", host="0.0.0.0", port=8080)
The operator serves a container server on port 8080 at /mcp and registers it
in core under the MCPServer's name:
kubectl apply -f server.yaml
kubectl -n mcp-hangar wait --for=condition=Ready mcpserver/notes --timeout=5m
kubectl -n mcp-hangar get mcpserver notes
NAME MODE STATE TOOLS READY AGE
notes container Ready 2 True 40s
4. Call a tool through core
Forward core's port and define a small helper. Core answers MCP over streamable
HTTP, and its reply to a POST is an event stream, so the helper keeps only the
data: line before jq reads it. In the default egress mode a client calls a
server's tool through hangar_call:
kubectl -n mcp-hangar port-forward svc/mcp-hangar 8080:8080 > /dev/null 2>&1 &
sleep 3
call() {
curl -s http://127.0.0.1:8080/mcp \
-H "X-API-Key: $HANGAR_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hangar_call",
"arguments":{"calls":[{"mcp_server":"notes","tool":"'"$1"'","arguments":'"$2"'}]}}}' \
| sed -n 's/^data: //p' \
| jq '.result.structuredContent.results[0] | {success, result: .result.structuredContent.result, error, error_type}'
}
call add '{"a": 2, "b": 3}'
call read_env '{"name": "DEMO_TOKEN"}'
Both calls go through:
{
"success": true,
"result": 5,
"error": null,
"error_type": null
}
{
"success": true,
"result": "not-a-real-secret",
"error": null,
"error_type": null
}
read_env hands the server's environment, DEMO_TOKEN included, to whoever
can call it. That is the tool to take away.
Before you do, note what the server's pod can reach. This helper opens a TCP connection from the server's pod to core's Service:
probe() {
kubectl -n mcp-hangar exec deploy/mcp-provider-notes -- python3 -c \
"import socket; socket.create_connection(('mcp-hangar', 8080), timeout=5)" \
> /dev/null 2>&1 && echo reachable || echo blocked
}
probe
It prints reachable.
5. Deny the tool
Save the policy as policy.yaml. mode: Enforce blocks instead of only
recording; deny wins over allow, so every tool but read_env stays
callable. The single upstream is 192.0.2.0/24, a range reserved for
documentation, so the server may open connections to nothing real:
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
name: notes-no-env
namespace: mcp-hangar
spec:
mode: Enforce
targetRef:
kind: MCPServer
name: notes
defaultAction: Deny
upstreams:
- name: documentation-range
match:
host: 192.0.2.0/24
tools:
allow: ["*"]
deny: ["read_env"]
The operator compiles the tool rules and pushes them to core. L7Delivered
turns True once core holds them:
kubectl apply -f policy.yaml
kubectl -n mcp-hangar wait --for=condition=L7Delivered mcpegresspolicy/notes-no-env --timeout=2m
6. Call it again
The same two calls:
call add '{"a": 2, "b": 3}'
call read_env '{"name": "DEMO_TOKEN"}'
add still answers 5. read_env is refused by core before anything reaches
the server:
{
"success": false,
"result": null,
"error": "Refused by MCP Hangar (l7): deny. See why: mcp-hangar why 846f289b-ebd7-4c74-b46e-55e70414d441\nTool call denied by egress policy",
"error_type": "EgressPolicyDeniedError"
}
The first line of error names the layer that refused (l7, the egress
policy) and the correlation id to look the decision up by. error_type is the
stable part to match on.
7. See the backstop
A tool rule in core governs the calls core proxies. It does not stop the
server's own process from opening connections, so the operator also writes a
network backstop: a NetworkPolicy that lets the server's pod reach DNS and
the policy's upstreams, and nothing else.
kubectl -n mcp-hangar get mcpegresspolicies
kubectl -n mcp-hangar get networkpolicy mcp-egresspolicy-notes-no-env -o yaml
probe
NAME MODE BACKSTOP L7 TARGET DEFAULT AGE
notes-no-env Enforce Unenforced True notes Deny 30s
The NetworkPolicy selects mcp-hangar.io/provider: notes and allows egress
to kube-dns on port 53 and to 192.0.2.0/24. The probe that printed
reachable before now prints blocked: kind's network plugin enforces the
backstop.
BACKSTOP still reads Unenforced, and the policy reports Degraded=True
with reason EnforcementNotObserved. The operator decides whether anything
enforces a NetworkPolicy by looking for a CNI it recognises, and it does not
recognise kind's. It reports what it could establish rather than assume. On a
cluster whose CNI it does not recognise but you know enforces policy, set
operator.networkPolicyEnforcement: enforced in the operator chart's values.
On Calico or Cilium it reads Enforcing by itself. An operator release with
mcp-hangar-operator#393
decides this from a block its own probe observes instead of the CNI's name,
and then reads Enforcing here too.
kubectl -n mcp-hangar get mcpegresspolicy notes-no-env \
-o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'
Compiled=True (Compiled)
BackstopApplied=True (BackstopApplied)
BackstopEnforceable=False (NoEnforcerObserved)
Degraded=True (EnforcementNotObserved)
L7Delivered=True (Delivered)
8. Fence the server's ingress
The backstop limits where the server's pod may connect. It does not limit who
may connect to the server: any pod in the cluster can still call it directly
on its pod address and skip core. The operator can fence that too. With
providerIngress.enabled, it writes a second NetworkPolicy per container
server that admits TCP 8080 only from core's pods in the gateway namespace.
First, the baseline. This helper starts a throwaway pod that is not core, in core's namespace, and opens a connection to the server's pod address:
PROVIDER_IP=$(kubectl -n mcp-hangar get pod -l mcp-hangar.io/provider=notes \
-o jsonpath='{.items[0].status.podIP}')
knock() {
kubectl -n mcp-hangar run "knock-$1" --rm -i --restart=Never --quiet \
--image=ghcr.io/mcp-hangar/mcp-hangar:2.36.1@sha256:71c6a52a8880e910f21ed05ead8178343aee721dd1ac544ebf55349d7aac213f \
--command -- python3 -c \
"import socket; socket.create_connection(('$PROVIDER_IP', 8080), timeout=5)" \
> /dev/null 2>&1 && echo "$1: reachable" || echo "$1: blocked"
}
knock before
It prints before: reachable. Now turn the fence on. Core runs in the
mcp-hangar namespace, and the operator finds its pods by the label
app.kubernetes.io/name=mcp-hangar:
helm upgrade mcp-hangar-operator oci://ghcr.io/mcp-hangar/charts/mcp-hangar-operator \
--namespace mcp-hangar --reuse-values \
--set providerIngress.enabled=true --set providerIngress.gatewayNamespace=mcp-hangar \
--wait --timeout 5m
for i in $(seq 60); do
kubectl -n mcp-hangar get networkpolicy notes-ingress > /dev/null 2>&1 && break
sleep 2
done
kubectl -n mcp-hangar get networkpolicy notes-ingress \
-o jsonpath='{.spec.ingress[0].from[0].namespaceSelector.matchLabels}{" "}{.spec.ingress[0].from[0].podSelector.matchLabels}{"\n"}'
sleep 10
knock after
The policy admits pods labelled app.kubernetes.io/name: mcp-hangar in the
namespace mcp-hangar, and nothing else, so the same knock now prints
after: blocked. Core is the gateway those labels name, so a call through it
still goes through:
kubectl -n mcp-hangar wait --for=condition=Ready mcpserver/notes --timeout=2m
call add '{"a": 2, "b": 3}'
kubectl -n mcp-hangar get mcpserver notes \
-o jsonpath='{range .status.conditions[?(@.type=="ProviderIngressApplied")]}{.type}={.status} ({.reason}){"\n"}{end}'
add answers 5 as before. ProviderIngressApplied reports whether the
fence is enforced on the same evidence as the backstop's BACKSTOP column, so
on kind it reads False (EnforcementNotObserved) until the operator release
above, although the knock shows the block.
The fence is opt-in for the whole operator, and one server can opt out with
the annotation mcp-hangar.io/provider-ingress: "false". Other
NetworkPolicy objects that select the server's pod can only add allowed
sources, never remove them, so check for those before you rely on the fence.
9. Clean up
kind delete cluster --name hangar
Where to go next
- Egress Policy: the full policy language, backstop flavors and every status condition
- Kubernetes Integration: the operator's resources and governed namespaces
- Authentication & Authorization: keys, roles and OIDC instead of one admin key
- Quick Start: the same idea on one laptop without a cluster