跳到正文
GitHub·· 9 天前AI 评分50

mcp-hangar-operator:用 Kubernetes Operator 管理 MCP 服务器

mcp-hangar/mcp-hangar-operator

AI 导读

mcp-hangar-operator 是一个 Kubernetes Operator,把 MCP(Model Context Protocol)工具服务器作为原生资源管理。

正文

Go License: MIT Kubernetes

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 MCPServer state against a healthPolicy
  • MCPDiscoverySource CRD: Automatic server discovery from namespaces
  • State Machine: Automatic lifecycle management (Cold → Initializing → Ready → Degraded → Dead)
  • Health: Hangar's health endpoint for remote servers and pod phase for container, 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 disables

The 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-mode MCPServer. For an external endpoint provider, set spec.mode: remote and spec.endpoint instead of spec.image; for Secret-backed config, add spec.env (corev1.EnvVar) or a spec.volumes entry plus a matching spec.volumeMounts entry referencing a Secret. spec.env, spec.volumes, spec.volumeMounts, spec.resources, spec.tolerations, spec.podSecurityContext and spec.containerSecurityContext are the corev1 types verbatim, so anything a PodSpec accepts is accepted here.
  • mcp-hangar_v1alpha2_mcpservergroup.yaml - Group of MCPServers selected by label, with a healthPolicy the 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


Docs | GitHub

来源:GitHub · github.com