whitecapdata-dev 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- homelab_mcp/__init__.py +12 -0
- homelab_mcp/config.py +54 -0
- homelab_mcp/format.py +125 -0
- homelab_mcp/kube.py +118 -0
- homelab_mcp/server.py +113 -0
- whitecapdata_dev-0.1.0.dist-info/METADATA +136 -0
- whitecapdata_dev-0.1.0.dist-info/RECORD +10 -0
- whitecapdata_dev-0.1.0.dist-info/WHEEL +4 -0
- whitecapdata_dev-0.1.0.dist-info/entry_points.txt +2 -0
- whitecapdata_dev-0.1.0.dist-info/licenses/LICENSE +21 -0
homelab_mcp/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""homelab-mcp — operate a k3s / Kubernetes cluster from an MCP client.
|
|
2
|
+
|
|
3
|
+
Exposes read and (guarded) write tools over the Kubernetes API so an agent
|
|
4
|
+
(Claude Code, Claude Desktop, Cursor, …) can inspect cluster health and perform
|
|
5
|
+
safe, allowlisted operations — without shelling out to kubectl.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .kube import HomelabMCPError, KubeClient
|
|
10
|
+
|
|
11
|
+
__all__ = ["Config", "KubeClient", "HomelabMCPError", "__version__"]
|
|
12
|
+
__version__ = "0.1.0"
|
homelab_mcp/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Environment-driven configuration for the homelab-mcp server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Sensible homelab default: only let mutating tools touch these namespaces.
|
|
9
|
+
# Override with HOMELAB_MCP_MUTABLE_NAMESPACES; set it to "*" to allow all.
|
|
10
|
+
DEFAULT_MUTABLE_NAMESPACES = ("default", "apps", "monitoring", "ci")
|
|
11
|
+
DEFAULT_MAX_REPLICAS = 10
|
|
12
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Config:
|
|
17
|
+
"""Effective server configuration, sourced from the environment."""
|
|
18
|
+
|
|
19
|
+
context: str | None = None
|
|
20
|
+
read_only: bool = False
|
|
21
|
+
mutable_namespaces: tuple[str, ...] = DEFAULT_MUTABLE_NAMESPACES
|
|
22
|
+
max_replicas: int = DEFAULT_MAX_REPLICAS
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_env(cls, env: dict[str, str] | None = None) -> Config:
|
|
26
|
+
src = os.environ if env is None else env
|
|
27
|
+
raw_ns = src.get("HOMELAB_MCP_MUTABLE_NAMESPACES")
|
|
28
|
+
if raw_ns is None:
|
|
29
|
+
namespaces = DEFAULT_MUTABLE_NAMESPACES
|
|
30
|
+
elif raw_ns.strip() == "*":
|
|
31
|
+
namespaces = () # empty tuple == every namespace allowed
|
|
32
|
+
else:
|
|
33
|
+
namespaces = tuple(n.strip() for n in raw_ns.split(",") if n.strip())
|
|
34
|
+
return cls(
|
|
35
|
+
context=src.get("HOMELAB_MCP_CONTEXT") or None,
|
|
36
|
+
read_only=src.get("HOMELAB_MCP_READONLY", "").lower() in _TRUE,
|
|
37
|
+
mutable_namespaces=namespaces,
|
|
38
|
+
max_replicas=int(src.get("HOMELAB_MCP_MAX_REPLICAS", str(DEFAULT_MAX_REPLICAS))),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def namespace_allowed(self, namespace: str) -> bool:
|
|
42
|
+
"""True if mutating tools may act on ``namespace``.
|
|
43
|
+
|
|
44
|
+
An empty allowlist means "all namespaces" (the operator opted in via ``*``).
|
|
45
|
+
"""
|
|
46
|
+
return not self.mutable_namespaces or namespace in self.mutable_namespaces
|
|
47
|
+
|
|
48
|
+
def as_dict(self) -> dict[str, object]:
|
|
49
|
+
return {
|
|
50
|
+
"context": self.context or "(current-context)",
|
|
51
|
+
"read_only": self.read_only,
|
|
52
|
+
"mutable_namespaces": list(self.mutable_namespaces) or ["*"],
|
|
53
|
+
"max_replicas": self.max_replicas,
|
|
54
|
+
}
|
homelab_mcp/format.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Pure mappers from Kubernetes API objects to plain JSON-able structures.
|
|
2
|
+
|
|
3
|
+
These take any object that exposes the same attributes as the official
|
|
4
|
+
``kubernetes`` client models, so they're unit-testable with lightweight fakes
|
|
5
|
+
(e.g. ``types.SimpleNamespace``) — no cluster required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _attr(obj: Any, *path: str, default: Any = None) -> Any:
|
|
14
|
+
"""Safely walk a chain of attributes, returning ``default`` on any miss."""
|
|
15
|
+
cur = obj
|
|
16
|
+
for name in path:
|
|
17
|
+
if cur is None:
|
|
18
|
+
return default
|
|
19
|
+
cur = getattr(cur, name, None)
|
|
20
|
+
return cur if cur is not None else default
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pod_is_healthy(pod: Any) -> bool:
|
|
24
|
+
"""A pod is healthy if it's Running/Succeeded and all containers are ready."""
|
|
25
|
+
phase = _attr(pod, "status", "phase", default="")
|
|
26
|
+
if phase == "Succeeded":
|
|
27
|
+
return True
|
|
28
|
+
if phase != "Running":
|
|
29
|
+
return False
|
|
30
|
+
statuses = _attr(pod, "status", "container_statuses", default=[]) or []
|
|
31
|
+
return all(getattr(cs, "ready", False) for cs in statuses)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pod_row(pod: Any) -> dict[str, Any]:
|
|
35
|
+
statuses = _attr(pod, "status", "container_statuses", default=[]) or []
|
|
36
|
+
restarts = sum(getattr(cs, "restart_count", 0) or 0 for cs in statuses)
|
|
37
|
+
return {
|
|
38
|
+
"namespace": _attr(pod, "metadata", "namespace", default=""),
|
|
39
|
+
"name": _attr(pod, "metadata", "name", default=""),
|
|
40
|
+
"phase": _attr(pod, "status", "phase", default=""),
|
|
41
|
+
"ready": pod_is_healthy(pod),
|
|
42
|
+
"restarts": restarts,
|
|
43
|
+
"node": _attr(pod, "spec", "node_name", default=""),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pod_rows(pods: list[Any]) -> list[dict[str, Any]]:
|
|
48
|
+
"""Rows for a pod list, unhealthy first then by namespace/name."""
|
|
49
|
+
rows = [pod_row(p) for p in pods]
|
|
50
|
+
rows.sort(key=lambda r: (r["ready"], r["namespace"], r["name"]))
|
|
51
|
+
return rows
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def deployment_row(dep: Any) -> dict[str, Any]:
|
|
55
|
+
desired = _attr(dep, "spec", "replicas", default=0) or 0
|
|
56
|
+
ready = _attr(dep, "status", "ready_replicas", default=0) or 0
|
|
57
|
+
return {
|
|
58
|
+
"namespace": _attr(dep, "metadata", "namespace", default=""),
|
|
59
|
+
"name": _attr(dep, "metadata", "name", default=""),
|
|
60
|
+
"ready_replicas": ready,
|
|
61
|
+
"desired_replicas": desired,
|
|
62
|
+
"available": ready >= desired and desired > 0,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def deployment_rows(deps: list[Any]) -> list[dict[str, Any]]:
|
|
67
|
+
rows = [deployment_row(d) for d in deps]
|
|
68
|
+
rows.sort(key=lambda r: (r["available"], r["namespace"], r["name"]))
|
|
69
|
+
return rows
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def node_row(node: Any) -> dict[str, Any]:
|
|
73
|
+
conditions = _attr(node, "status", "conditions", default=[]) or []
|
|
74
|
+
ready = any(getattr(c, "type", "") == "Ready" and getattr(c, "status", "") == "True" for c in conditions)
|
|
75
|
+
# Pressure conditions are healthy when "False".
|
|
76
|
+
pressures = {
|
|
77
|
+
getattr(c, "type", ""): getattr(c, "status", "")
|
|
78
|
+
for c in conditions
|
|
79
|
+
if getattr(c, "type", "").endswith("Pressure")
|
|
80
|
+
}
|
|
81
|
+
cap = _attr(node, "status", "capacity", default={}) or {}
|
|
82
|
+
return {
|
|
83
|
+
"name": _attr(node, "metadata", "name", default=""),
|
|
84
|
+
"ready": ready,
|
|
85
|
+
"kubelet": _attr(node, "status", "node_info", "kubelet_version", default=""),
|
|
86
|
+
"cpu": cap.get("cpu", ""),
|
|
87
|
+
"memory": cap.get("memory", ""),
|
|
88
|
+
"pressure": [k for k, v in pressures.items() if v == "True"],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def node_rows(nodes: list[Any]) -> list[dict[str, Any]]:
|
|
93
|
+
return [node_row(n) for n in nodes]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cluster_summary(nodes: list[Any], pods: list[Any]) -> dict[str, Any]:
|
|
97
|
+
node_data = node_rows(nodes)
|
|
98
|
+
pod_data = pod_rows(pods)
|
|
99
|
+
unhealthy = [p for p in pod_data if not p["ready"]]
|
|
100
|
+
phases: dict[str, int] = {}
|
|
101
|
+
for p in pod_data:
|
|
102
|
+
phases[p["phase"]] = phases.get(p["phase"], 0) + 1
|
|
103
|
+
return {
|
|
104
|
+
"nodes": {"total": len(node_data), "ready": sum(1 for n in node_data if n["ready"])},
|
|
105
|
+
"pods": {"total": len(pod_data), "by_phase": phases, "unhealthy": len(unhealthy)},
|
|
106
|
+
"unhealthy_pods": unhealthy,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def event_row(ev: Any) -> dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"type": _attr(ev, "type", default=""),
|
|
113
|
+
"reason": _attr(ev, "reason", default=""),
|
|
114
|
+
"object": f"{_attr(ev, 'involved_object', 'kind', default='')}/"
|
|
115
|
+
f"{_attr(ev, 'involved_object', 'name', default='')}",
|
|
116
|
+
"namespace": _attr(ev, "metadata", "namespace", default=""),
|
|
117
|
+
"message": _attr(ev, "message", default=""),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def event_rows(events: list[Any], limit: int) -> list[dict[str, Any]]:
|
|
122
|
+
rows = [event_row(e) for e in events]
|
|
123
|
+
# Warnings first so problems surface at the top.
|
|
124
|
+
rows.sort(key=lambda r: r["type"] != "Warning")
|
|
125
|
+
return rows[:limit]
|
homelab_mcp/kube.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Kubernetes API wrapper used by the MCP tools.
|
|
2
|
+
|
|
3
|
+
Reads happen through the CoreV1/AppsV1 APIs and are shaped by :mod:`format`.
|
|
4
|
+
Mutations are gated on the operator's config (read-only switch + namespace
|
|
5
|
+
allowlist) *before* any API call, so a misbehaving agent can't scale or delete
|
|
6
|
+
outside the sandbox the operator allowed.
|
|
7
|
+
|
|
8
|
+
The official ``kubernetes`` client is imported lazily so the pure logic (and
|
|
9
|
+
its tests) don't require it to be installed or a cluster to be reachable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import format as fmt
|
|
18
|
+
from .config import Config
|
|
19
|
+
|
|
20
|
+
RESTART_ANNOTATION = "kubectl.kubernetes.io/restartedAt"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HomelabMCPError(RuntimeError):
|
|
24
|
+
"""A user-facing error (bad config, blocked mutation, or API failure)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_apis(config: Config) -> tuple[Any, Any]:
|
|
28
|
+
"""Load kube config (in-cluster, else kubeconfig) and return (core, apps)."""
|
|
29
|
+
try:
|
|
30
|
+
from kubernetes import client # noqa: PLC0415
|
|
31
|
+
from kubernetes import config as kube_config # noqa: PLC0415
|
|
32
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
33
|
+
raise HomelabMCPError(
|
|
34
|
+
"The 'kubernetes' package is required. Install with: pip install homelab-mcp"
|
|
35
|
+
) from exc
|
|
36
|
+
try:
|
|
37
|
+
kube_config.load_incluster_config()
|
|
38
|
+
except kube_config.ConfigException:
|
|
39
|
+
kube_config.load_kube_config(context=config.context)
|
|
40
|
+
return client.CoreV1Api(), client.AppsV1Api()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class KubeClient:
|
|
44
|
+
"""Thin, testable facade over the parts of the Kubernetes API we expose."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: Config, core_v1: Any = None, apps_v1: Any = None) -> None:
|
|
47
|
+
self._config = config
|
|
48
|
+
if core_v1 is None or apps_v1 is None:
|
|
49
|
+
core_v1, apps_v1 = _load_apis(config)
|
|
50
|
+
self._core = core_v1
|
|
51
|
+
self._apps = apps_v1
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def config(self) -> Config:
|
|
55
|
+
return self._config
|
|
56
|
+
|
|
57
|
+
# -- reads ---------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _pods(self, namespace: str = "") -> list[Any]:
|
|
60
|
+
if namespace:
|
|
61
|
+
return self._core.list_namespaced_pod(namespace).items
|
|
62
|
+
return self._core.list_pod_for_all_namespaces().items
|
|
63
|
+
|
|
64
|
+
def cluster_summary(self) -> dict[str, Any]:
|
|
65
|
+
nodes = self._core.list_node().items
|
|
66
|
+
return fmt.cluster_summary(nodes, self._pods())
|
|
67
|
+
|
|
68
|
+
def list_pods(self, namespace: str = "") -> list[dict[str, Any]]:
|
|
69
|
+
return fmt.pod_rows(self._pods(namespace))
|
|
70
|
+
|
|
71
|
+
def list_deployments(self, namespace: str = "") -> list[dict[str, Any]]:
|
|
72
|
+
if namespace:
|
|
73
|
+
deps = self._apps.list_namespaced_deployment(namespace).items
|
|
74
|
+
else:
|
|
75
|
+
deps = self._apps.list_deployment_for_all_namespaces().items
|
|
76
|
+
return fmt.deployment_rows(deps)
|
|
77
|
+
|
|
78
|
+
def list_events(self, limit: int = 30) -> list[dict[str, Any]]:
|
|
79
|
+
events = self._core.list_event_for_all_namespaces().items
|
|
80
|
+
return fmt.event_rows(events, limit)
|
|
81
|
+
|
|
82
|
+
def pod_logs(self, namespace: str, pod: str, tail: int = 200) -> str:
|
|
83
|
+
return self._core.read_namespaced_pod_log(name=pod, namespace=namespace, tail_lines=tail)
|
|
84
|
+
|
|
85
|
+
def node_health(self) -> list[dict[str, Any]]:
|
|
86
|
+
return fmt.node_rows(self._core.list_node().items)
|
|
87
|
+
|
|
88
|
+
# -- mutations (guarded) -------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _guard(self, namespace: str) -> None:
|
|
91
|
+
if self._config.read_only:
|
|
92
|
+
raise HomelabMCPError("Server is in read-only mode; mutations are disabled.")
|
|
93
|
+
if not self._config.namespace_allowed(namespace):
|
|
94
|
+
allowed = ", ".join(self._config.mutable_namespaces) or "*"
|
|
95
|
+
raise HomelabMCPError(
|
|
96
|
+
f"Namespace '{namespace}' is not in the mutable allowlist ({allowed})."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def restart_deployment(self, namespace: str, name: str) -> str:
|
|
100
|
+
self._guard(namespace)
|
|
101
|
+
stamp = datetime.now(UTC).isoformat()
|
|
102
|
+
body = {"spec": {"template": {"metadata": {"annotations": {RESTART_ANNOTATION: stamp}}}}}
|
|
103
|
+
self._apps.patch_namespaced_deployment(name=name, namespace=namespace, body=body)
|
|
104
|
+
return f"restarted deployment {namespace}/{name} at {stamp}"
|
|
105
|
+
|
|
106
|
+
def scale_deployment(self, namespace: str, name: str, replicas: int) -> str:
|
|
107
|
+
self._guard(namespace)
|
|
108
|
+
if not 0 <= replicas <= self._config.max_replicas:
|
|
109
|
+
raise HomelabMCPError(f"replicas must be between 0 and {self._config.max_replicas}.")
|
|
110
|
+
self._apps.patch_namespaced_deployment_scale(
|
|
111
|
+
name=name, namespace=namespace, body={"spec": {"replicas": replicas}}
|
|
112
|
+
)
|
|
113
|
+
return f"scaled deployment {namespace}/{name} to {replicas} replicas"
|
|
114
|
+
|
|
115
|
+
def delete_pod(self, namespace: str, name: str) -> str:
|
|
116
|
+
self._guard(namespace)
|
|
117
|
+
self._core.delete_namespaced_pod(name=name, namespace=namespace)
|
|
118
|
+
return f"deleted pod {namespace}/{name} (its controller will recreate it)"
|
homelab_mcp/server.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The homelab-mcp MCP server.
|
|
2
|
+
|
|
3
|
+
Tools return JSON strings so the calling agent gets structured, compact data.
|
|
4
|
+
Reads are always available; mutations are gated by the operator's config
|
|
5
|
+
(read-only switch + namespace allowlist) enforced in :class:`KubeClient`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .kube import KubeClient
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("homelab")
|
|
19
|
+
|
|
20
|
+
_client: KubeClient | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_client() -> KubeClient:
|
|
24
|
+
"""Lazily build the cluster client (so import never touches a cluster)."""
|
|
25
|
+
global _client
|
|
26
|
+
if _client is None:
|
|
27
|
+
_client = KubeClient(Config.from_env())
|
|
28
|
+
return _client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_client(client: KubeClient) -> None:
|
|
32
|
+
"""Replace the module-level client (used by tests)."""
|
|
33
|
+
global _client
|
|
34
|
+
_client = client
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _json(data: Any) -> str:
|
|
38
|
+
return json.dumps(data, indent=2, default=str)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# -- reads -------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
def cluster_summary() -> str:
|
|
46
|
+
"""Node and pod health totals plus the list of unhealthy pods. Start here."""
|
|
47
|
+
return _json(get_client().cluster_summary())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
def list_pods(namespace: str = "") -> str:
|
|
52
|
+
"""List pods (optionally one namespace). Unhealthy pods sort first."""
|
|
53
|
+
return _json(get_client().list_pods(namespace))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def list_deployments(namespace: str = "") -> str:
|
|
58
|
+
"""List deployments with ready/desired replica counts."""
|
|
59
|
+
return _json(get_client().list_deployments(namespace))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def list_events(limit: int = 30) -> str:
|
|
64
|
+
"""Recent cluster events; Warning-type events sort first."""
|
|
65
|
+
return _json(get_client().list_events(limit))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def pod_logs(namespace: str, pod: str, tail: int = 200) -> str:
|
|
70
|
+
"""Tail a pod's logs."""
|
|
71
|
+
return get_client().pod_logs(namespace, pod, tail)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
def node_health() -> str:
|
|
76
|
+
"""Per-node readiness, kubelet version, capacity, and pressure conditions."""
|
|
77
|
+
return _json(get_client().node_health())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -- mutations (guarded by read-only + namespace allowlist) ------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def restart_deployment(namespace: str, name: str) -> str:
|
|
85
|
+
"""Rollout-restart a deployment (subject to the mutable-namespace allowlist)."""
|
|
86
|
+
return get_client().restart_deployment(namespace, name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
def scale_deployment(namespace: str, name: str, replicas: int) -> str:
|
|
91
|
+
"""Scale a deployment to N replicas (0..max), subject to the allowlist."""
|
|
92
|
+
return get_client().scale_deployment(namespace, name, replicas)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@mcp.tool()
|
|
96
|
+
def delete_pod(namespace: str, name: str) -> str:
|
|
97
|
+
"""Delete a pod so its controller recreates it (subject to the allowlist)."""
|
|
98
|
+
return get_client().delete_pod(namespace, name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool()
|
|
102
|
+
def server_info() -> str:
|
|
103
|
+
"""Report the effective configuration (context, read-only, allowlist)."""
|
|
104
|
+
return _json(get_client().config.as_dict())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
"""Console-script entry point: run the server over stdio."""
|
|
109
|
+
mcp.run()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whitecapdata-dev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WhiteCapData-Dev — an MCP server to operate a k3s / Kubernetes cluster (health, logs, and guarded restart/scale/delete) straight from your AI agent.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Michael-WhiteCapData/WhiteCapData-Dev
|
|
6
|
+
Project-URL: Repository, https://github.com/Michael-WhiteCapData/WhiteCapData-Dev
|
|
7
|
+
Project-URL: Issues, https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/issues
|
|
8
|
+
Author: Michael Tierney
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: claude,devops,homelab,k3s,k8s,kubernetes,mcp,model-context-protocol
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: kubernetes>=29
|
|
21
|
+
Requires-Dist: mcp>=1.2
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# WhiteCapData-Dev
|
|
29
|
+
|
|
30
|
+
**Operate a k3s / Kubernetes cluster straight from your AI agent — safe by default.**
|
|
31
|
+
|
|
32
|
+
[](https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/whitecapdata-dev/)
|
|
34
|
+
[](https://www.python.org/)
|
|
35
|
+
[](https://modelcontextprotocol.io/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
An [MCP](https://modelcontextprotocol.io/) server that lets an agent (Claude Code, Claude Desktop, Cursor, …) inspect and operate a **Kubernetes / k3s** cluster — your homelab box, a dev cluster, whatever your kubeconfig points at — **without shelling out to `kubectl`**. It talks to the Kubernetes API directly using your existing kubeconfig (or an in-cluster service account).
|
|
39
|
+
|
|
40
|
+
The design goal is **safe by default**: reads are always on; every mutating action (restart / scale / delete) is gated *before the API call* by a read-only switch and a namespace allowlist, so an over-eager agent can't touch `kube-system` or nuke a deployment you didn't sandbox.
|
|
41
|
+
|
|
42
|
+
> **Name note:** the PyPI package is `whitecapdata-dev` (the `homelab-k8s`-style name was taken); the import package and tools are k8s/homelab-focused as described here.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Why you'd want this
|
|
47
|
+
|
|
48
|
+
- 🩺 **One-call health.** `cluster_summary` gives node + pod totals and the unhealthy pods, so the agent starts triage with real data.
|
|
49
|
+
- 🔒 **Safe by default.** Mutations are blocked unless the namespace is on your allowlist; flip `HOMELAB_MCP_READONLY=1` to make the whole server read-only.
|
|
50
|
+
- 🧰 **The operations you actually do.** Pods, deployments, events, logs, node health, rollout-restart, scale, delete-pod.
|
|
51
|
+
- 🪶 **No bespoke backend.** Uses the standard Kubernetes API + your kubeconfig — nothing to deploy server-side.
|
|
52
|
+
- ✅ **Tested.** Pure logic is unit-tested with fakes; guard logic is tested against a mocked API. No cluster needed to run the suite.
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- A reachable cluster and a working **kubeconfig** (the same one `kubectl` uses), or run it in-cluster with a service account.
|
|
57
|
+
- Python 3.11+ (or just `uvx`).
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uvx whitecapdata-dev # run directly
|
|
63
|
+
# or
|
|
64
|
+
pip install whitecapdata-dev # then run: whitecapdata-dev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Claude Code
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
claude mcp add homelab -- uvx whitecapdata-dev
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Claude Desktop / Cursor
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"homelab": {
|
|
79
|
+
"command": "uvx",
|
|
80
|
+
"args": ["whitecapdata-dev"],
|
|
81
|
+
"env": {
|
|
82
|
+
"HOMELAB_MCP_MUTABLE_NAMESPACES": "default,apps,monitoring",
|
|
83
|
+
"HOMELAB_MCP_READONLY": "0"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Tools
|
|
91
|
+
|
|
92
|
+
| Tool | Kind | Description |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `cluster_summary` | read | Node/pod health totals + unhealthy pods |
|
|
95
|
+
| `list_pods` | read | Pods (optionally one namespace), unhealthy first |
|
|
96
|
+
| `list_deployments` | read | Deployments with ready/desired replicas |
|
|
97
|
+
| `list_events` | read | Recent events, Warnings first |
|
|
98
|
+
| `pod_logs` | read | Tail a pod's logs |
|
|
99
|
+
| `node_health` | read | Per-node readiness, kubelet, capacity, pressure |
|
|
100
|
+
| `restart_deployment` | **write** | Rollout-restart (allowlisted namespaces) |
|
|
101
|
+
| `scale_deployment` | **write** | Scale to N replicas (0..max, allowlisted) |
|
|
102
|
+
| `delete_pod` | **write** | Delete a pod; its controller recreates it (allowlisted) |
|
|
103
|
+
| `server_info` | read | Effective config (context, read-only, allowlist) |
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Description |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `HOMELAB_MCP_CONTEXT` | current-context | kubeconfig context to use |
|
|
110
|
+
| `HOMELAB_MCP_READONLY` | `0` | `1`/`true` disables all mutating tools |
|
|
111
|
+
| `HOMELAB_MCP_MUTABLE_NAMESPACES` | `default,apps,monitoring,ci` | Namespaces mutations may touch; `*` = all |
|
|
112
|
+
| `HOMELAB_MCP_MAX_REPLICAS` | `10` | Upper bound for `scale_deployment` |
|
|
113
|
+
|
|
114
|
+
## Safety model
|
|
115
|
+
|
|
116
|
+
1. **Read-only switch** — `HOMELAB_MCP_READONLY=1` rejects every mutating tool up front.
|
|
117
|
+
2. **Namespace allowlist** — mutating tools refuse any namespace not in `HOMELAB_MCP_MUTABLE_NAMESPACES` (default a homelab-friendly set; `*` opts into all).
|
|
118
|
+
3. **Bounded scale** — `scale_deployment` clamps to `0..HOMELAB_MCP_MAX_REPLICAS`.
|
|
119
|
+
|
|
120
|
+
The cluster's own RBAC still applies on top — this server can only do what the kubeconfig identity is permitted to do.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone https://github.com/Michael-WhiteCapData/WhiteCapData-Dev
|
|
126
|
+
cd WhiteCapData-Dev
|
|
127
|
+
uv pip install -e ".[dev]"
|
|
128
|
+
ruff check .
|
|
129
|
+
pytest # no cluster required — APIs are faked/mocked
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
[MIT](LICENSE) © Michael Tierney
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
homelab_mcp/__init__.py,sha256=nYcCY6IHWd9AiTvbYryXTPSH2ehII6m8X2Gwt_TNKmY,468
|
|
2
|
+
homelab_mcp/config.py,sha256=f-h5nm7sn0T4--0jGcF6qjZukee4VDZDVsnNV_-40WA,2088
|
|
3
|
+
homelab_mcp/format.py,sha256=dQuDyBHWKU-g6FrzCZ-JOKlAgL-UEI30zY7V7r4b-Ug,4662
|
|
4
|
+
homelab_mcp/kube.py,sha256=KPNsGcMAJMu8xcw__6uNbdl_qzwY-RC3jY-u1XdCU_s,4925
|
|
5
|
+
homelab_mcp/server.py,sha256=oX7DR5rln6Q5XaLLd-Eo9z3fDR4FcSGd8oZJWXXDwTg,3100
|
|
6
|
+
whitecapdata_dev-0.1.0.dist-info/METADATA,sha256=Xg598St0ZJ93lsrx_pV3Azvnk1tQ_xOWWznC7ovwUDU,6117
|
|
7
|
+
whitecapdata_dev-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
whitecapdata_dev-0.1.0.dist-info/entry_points.txt,sha256=3kAnizcfuUcuUaoK5_kPU9jF0I7lqEone9sO5Mabssc,61
|
|
9
|
+
whitecapdata_dev-0.1.0.dist-info/licenses/LICENSE,sha256=CY7xjvDIH4rbWyhYFOZZaAfXsrsdo5apgxDnsY-xq8g,1072
|
|
10
|
+
whitecapdata_dev-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Tierney
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|