mcp-hangar-operator:用 Kubernetes Operator 管理 MCP 服务器
mcp-hangar/mcp-hangar-operator
mcp-hangar-operator 是一个 Kubernetes Operator,把 MCP(Model Context Protocol)工具服务器作为原生资源管理。
Kubernetes Operator for managing MCP (Model Context Protocol) providers as native Kubernetes resources.
Features
- MCPServer CRD: Declarative management of MCP tool servers
- MCPServerGroup CRD: Label-selected aggregation of
MCPServerstate against ahealthPolicy - MCPDiscoverySource CRD: Automatic server discovery from namespaces
- State Machine: Automatic lifecycle management (Cold → Initializing → Ready → Degraded → Dead)
- Health: Hangar's health endpoint for
remoteservers and pod phase forcontainer, on the reconcile interval - Metrics: Prometheus metrics for monitoring
- Secure by Default: Pod security contexts, non-root, read-only filesystem
- Policy Enforcement: Opt-in namespaces get default-deny egress, admission-time registration, and image-pin coupling (see Enforcement)
Quick Start
Prerequisites
- Kubernetes 1.25+
- Helm 3.x
- kubectl configured for your cluster
Installation
The chart version is owned by release-please and tracked in the release compatibility matrix — check there for the current, verified version before installing.
# Install via OCI registry (pin --version to the current chart version from # the compatibility matrix above) helm install mcp-hangar-operator oci://ghcr.io/mcp-hangar/charts/mcp-hangar-operator \ --version <chart-version> \ --namespace mcp-hangar \ --create-namespace
Create Your First Server
apiVersion: mcp-hangar.io/v1alpha2 kind: MCPServer metadata: name: my-tools namespace: default spec: mode: container image: ghcr.io/my-org/my-mcp-tools:latest replicas: 1 resources: requests: memory: "128Mi" cpu: "100m"
kubectl apply -f my-server.yaml kubectl get mcpservers
CRD Reference
The operator is the deploy-time admission plane, not Hangar's control plane.
Idle stop, circuit breaking and tool allow-lists are core settings
(config.yaml / the REST API) and are not MCPServer fields — a cluster server
discovered by Hangar takes core's own idle_ttl_s default. The tables below
list what the operator actually reads.
MCPServer
| Field | Description | Default |
|---|---|---|
spec.mode |
Execution mode: container or remote |
Required |
spec.image |
Container image (for container mode) | Required for container |
spec.endpoint |
HTTP endpoint (for remote mode) | Required for remote |
spec.replicas |
Number of replicas (0 = cold start) | 1 |
spec.startupTimeout |
How long to wait for the server to come up | 30s |
spec.shutdownGracePeriod |
Pod termination grace period | 30s |
spec.capabilities.network |
Egress the server declares; feeds the generated NetworkPolicy |
— |
spec.capabilities.tools |
maxCount / expectedTools; drives capability-violation events |
— |
spec.capabilities.enforcementMode |
What a violation does (audit / block) |
— |
MCPServerGroup
A status aggregator, not a load balancer: it selects MCPServers, counts
their states, and reports Ready/Degraded/Available against a healthPolicy.
Traffic is not routed through it.
| Field | Description | Default |
|---|---|---|
spec.selector |
Label selector for member servers | Required |
spec.healthPolicy.minHealthyPercentage |
Minimum healthy members | 50 |
spec.healthPolicy.minHealthyCount |
Minimum healthy members, absolute | — |
MCPDiscoverySource
| Field | Description | Default |
|---|---|---|
spec.type |
Discovery type: Namespace, ConfigMap, Annotations | Required |
spec.mode |
Discovery mode: Additive, Authoritative | Additive |
spec.refreshInterval |
Rescan interval | 1m |
Enforcement
The operator can make the governed path the default path: in an opted-in namespace an MCP server only reaches the network once it is registered and its image is pinned. Enforcement is opt-in per namespace — label the namespace to turn it on, so it never gates workloads elsewhere:
kubectl label namespace my-mcp mcp-hangar.io/enforce-egress=true
Inside an enforced namespace three controls apply:
| Control | Behaviour | Where |
|---|---|---|
| Default-deny egress | A server with no egress policy gets DNS only — not "all egress". Egress opens only when a policy is generated for it. | pkg/networkpolicy/builder.go |
| Admission registration | A Pod labelled mcp-hangar.io/provider=<name> is denied at admission unless an MCPServer named <name> exists in the namespace, and the label is immutable once the Pod is admitted (it can be removed, not added or changed). Shadow/unregistered provider pods fail to deploy, and an admitted Pod cannot be relabelled into a registered server's egress. |
internal/webhook/pod_registration_webhook.go (OWASP MCP09) |
| Pin coupling | A registered container-mode server's egress allow-policy is opened only if its image is digest-pinned (image@sha256:...). An unpinned server stays under default-deny (DNS only) and gets an EgressWithheldUnpinnedImage event. Opt out per server with the hangar.io/allow-mutable-image="true" annotation. |
internal/controller/mcpserver_controller.go |
FQDN/host egress rules fail closed — a rule that a Kubernetes
NetworkPolicy cannot express (host/FQDN with no CIDR) emits no permissive rule.
Declarative L7/FQDN egress is handled by the MCPEgressPolicy API and its
Cilium/Tetragon backstop (ADR-006).
CIDR rules on Cilium do not reach in-cluster upstreams
Cilium matches policy on security identities, not IPs. By default a CIDR
selector — a NetworkPolicy ipBlock (what capabilities.network.egress[].cidr
becomes) or a CiliumNetworkPolicy toCIDR (what an MCPEgressPolicy CIDR
upstream becomes) — does not match cluster-internal pod or node IPs
(Cilium docs). The
rule is accepted, matches nothing, and the traffic is dropped on the wire. The
same rule works on Calico. Cluster operators opt in with:
cilium install --set policyCIDRMatchMode='{pods}' # or --policy-cidr-match-mode=pods
When the operator detects Cilium (its CRD is installed), an MCPServer whose
egress CIDR falls in a range in-cluster IPs come from (RFC1918, CGNAT
100.64.0.0/10, IPv6 ULA) gets an admission warning naming that flag, and
an MCPEgressPolicy with such an upstream says so in its BackstopApplied
condition message. It is a warning, never a rejection: the operator cannot read
the CNI's flag state, and a private range is equally likely to be a legitimate
external upstream (an on-prem database at 10.x). If the destination really is
in-cluster, set policyCIDRMatchMode — a hostname does not route around this,
since toFQDNs resolves to the same CIDR identities. See #152.
A backstop that was written is not a backstop that is enforced
The operator writes a NetworkPolicy (or CiliumNetworkPolicy) to the API
server it holds. Where that is not the API server the CNI watches -- a vcluster
with sync.toHost.networkPolicies off is the case that produced #172, but any
split control plane has the shape -- the object is stored, the write succeeds,
and nothing ever reads it. The pod the policy claims to govern keeps its egress.
An MCPEgressPolicy therefore reports what it could observe, next to what was
asked for:
$ kubectl get mcpegresspolicies -n providers NAME MODE BACKSTOP TARGET DEFAULT AGE notes-local-only Enforce Unenforced notes Deny 3d15h
spec.mode is the request; status.backstopEnforcement is the observation.
Unenforced means the operator looked for a policy-enforcing API served here
and for a known CNI agent running here, found neither, and is telling you the
backstop it just wrote has no reader. The policy is also Degraded with reason
EnforcementNotObserved, and the BackstopEnforceable condition names what was
looked for. Unverified means the operator could not tell (it may not list
DaemonSets, say) -- doubt, recorded but not degraded.
The look is evidence, not proof: an agent can be installed and broken. For a CNI this operator does not recognize, or the reverse, override it:
--networkpolicy-enforcement=auto|enforced|unenforced # default: auto
Only status is affected. Nothing stops being written, and the L7 half of a policy -- the tool, argument and header rules core enforces -- is unaffected by any of this.
A gateway that restarts gets its L7 policies back
The L7 half is pushed to core over --hangar-url. Where core has no durable
persistence backend (the chart default), that policy lives in the gateway's
memory and is gone after the pod restarts, while the MCPEgressPolicy still
reports Compiled. So the operator watches the gateway's pods, and when one
becomes Ready it reconciles every MCPEgressPolicy, re-pushing its L7 policy
within seconds:
--hangar-gateway-selector=app.kubernetes.io/name=mcp-hangar # default; empty disablesThe default matches the mcp-hangar chart. Set it if your gateway pods carry other labels. A selector that matches no pod makes this a no-op, so the operator logs an error-level line saying so at startup, and again on a push at most every ten minutes while it still matches nothing. With more than one gateway replica and no shared backend, a push reaches whichever replica the Service routes it to; a durable backend is what makes one push reach them all.
Examples
See config/samples/ for complete, runnable examples:
mcp-hangar_v1alpha2_mcpserver.yaml- Basic container-modeMCPServer. For an external endpoint provider, setspec.mode: remoteandspec.endpointinstead ofspec.image; for Secret-backed config, addspec.env(corev1.EnvVar) or aspec.volumesentry plus a matchingspec.volumeMountsentry referencing aSecret.spec.env,spec.volumes,spec.volumeMounts,spec.resources,spec.tolerations,spec.podSecurityContextandspec.containerSecurityContextare thecorev1types verbatim, so anything aPodSpecaccepts is accepted here.mcp-hangar_v1alpha2_mcpservergroup.yaml- Group ofMCPServers selected by label, with ahealthPolicythe group reports against.mcp-hangar_v1alpha2_mcpdiscoverysource.yaml- Automatic server discovery from a namespace.
Apply all samples at once:
kubectl apply -k config/samples/
Metrics
The operator exposes Prometheus metrics at :8080/metrics:
| Metric | Description |
|---|---|
controller_runtime_reconcile_total |
Total reconciliations (controller-runtime built-in) |
controller_runtime_reconcile_time_seconds |
Reconciliation duration (controller-runtime built-in) |
mcp_operator_provider_state |
Provider state (1 = in state) |
mcp_operator_provider_tools_count |
Tools per provider |
mcp_operator_provider_health_check_failures_total |
Health check failures |
Development
# Run locally make run # Run all tests make test # Run tests with coverage go test ./... -cover # Run specific package tests go test ./pkg/provider -v go test ./pkg/hangar -v go test ./pkg/metrics -v go test ./internal/controller -v # Build image make docker-build IMG=my-registry/mcp-hangar-operator:v0.1.0 # Push image make docker-push IMG=my-registry/mcp-hangar-operator:v0.1.0
Testing
- pkg/provider: Pod builder
- pkg/hangar: Hangar client
- pkg/metrics: Prometheus metrics
- internal/controller: Controller config
Run make test to execute the full suite with coverage (go test ./... -coverprofile cover.out); run go tool cover -func cover.out afterwards for a per-package breakdown.
License
MIT License
来源:GitHub · github.com