whitecapdata-dev 0.1.0__tar.gz

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.
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+
21
+ - name: Create venv (Python ${{ matrix.python-version }})
22
+ run: uv venv -p ${{ matrix.python-version }}
23
+
24
+ - name: Install project (with dev deps)
25
+ run: uv pip install -e ".[dev]"
26
+
27
+ - name: Lint
28
+ run: uv run ruff check .
29
+
30
+ - name: Test
31
+ run: uv run pytest --cov=homelab_mcp --cov-report=term-missing
@@ -0,0 +1,36 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - name: Install uv
13
+ uses: astral-sh/setup-uv@v5
14
+ - name: Build sdist and wheel
15
+ run: uv build
16
+ - name: Upload dist artifact
17
+ uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ # Trusted Publishing (OIDC) — no API token stored in the repo.
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write
29
+ steps:
30
+ - name: Download dist artifact
31
+ uses: actions/download-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+ - name: Publish to PyPI
36
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Tooling
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+ .mypy_cache/
17
+
18
+ # Editors / OS
19
+ .idea/
20
+ .vscode/
21
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-06-20
8
+
9
+ ### Added
10
+ - Read tools: `cluster_summary`, `list_pods`, `list_deployments`, `list_events`,
11
+ `pod_logs`, `node_health`, `server_info`.
12
+ - Guarded mutating tools: `restart_deployment`, `scale_deployment`, `delete_pod`.
13
+ - Safe-by-default model: read-only switch (`HOMELAB_MCP_READONLY`), namespace
14
+ allowlist (`HOMELAB_MCP_MUTABLE_NAMESPACES`, `*` for all), bounded scale
15
+ (`HOMELAB_MCP_MAX_REPLICAS`).
16
+ - kubeconfig/in-cluster auth with optional context (`HOMELAB_MCP_CONTEXT`).
17
+ - Unit tests for config, mappers, guard logic, and the tool layer (no cluster
18
+ required). GitHub Actions CI on Python 3.11 and 3.12.
19
+ - MCP registry manifest (`server.json`) and PyPI Trusted Publishing workflow.
20
+
21
+ [0.1.0]: https://github.com/Michael-WhiteCapData/homelab-mcp/releases/tag/v0.1.0
@@ -0,0 +1,33 @@
1
+ # Contributing to homelab-mcp
2
+
3
+ Thanks for your interest! This server stays small, focused, and **safe by default** — contributions that preserve those properties merge easiest.
4
+
5
+ ## Getting set up
6
+
7
+ ```bash
8
+ git clone https://github.com/Michael-WhiteCapData/homelab-mcp
9
+ cd homelab-mcp
10
+ uv pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Before opening a PR
14
+
15
+ - `ruff check .` passes (`ruff check --fix .` to autofix).
16
+ - `pytest` passes. The suite uses fakes/mocks for the Kubernetes API — **no cluster required**.
17
+ - New behavior comes with a test.
18
+ - Any new **mutating** tool must go through `KubeClient._guard()` (read-only + namespace allowlist) and have a test proving it's blocked when it should be.
19
+
20
+ ## Architecture
21
+
22
+ - `config.py` — env-driven config + the namespace allowlist logic.
23
+ - `format.py` — pure mappers from Kubernetes objects to JSON-able dicts (easy to test).
24
+ - `kube.py` — the API facade; reads call mappers, mutations are guarded.
25
+ - `server.py` — the MCP tool layer (thin; delegates to `KubeClient`).
26
+
27
+ ## Reporting bugs
28
+
29
+ Open an issue with what you ran, expected vs. actual, and your `server_info` output.
30
+
31
+ ## Code of conduct
32
+
33
+ Be decent, assume good faith, keep it constructive.
@@ -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.
@@ -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
+ [![CI](https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/actions/workflows/ci.yml/badge.svg)](https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/whitecapdata-dev?color=3775A9&logo=pypi&logoColor=white)](https://pypi.org/project/whitecapdata-dev/)
34
+ [![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
35
+ [![MCP](https://img.shields.io/badge/MCP-server-D97757)](https://modelcontextprotocol.io/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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,109 @@
1
+ # WhiteCapData-Dev
2
+
3
+ **Operate a k3s / Kubernetes cluster straight from your AI agent — safe by default.**
4
+
5
+ [![CI](https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/actions/workflows/ci.yml/badge.svg)](https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/whitecapdata-dev?color=3775A9&logo=pypi&logoColor=white)](https://pypi.org/project/whitecapdata-dev/)
7
+ [![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
8
+ [![MCP](https://img.shields.io/badge/MCP-server-D97757)](https://modelcontextprotocol.io/)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
10
+
11
+ 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).
12
+
13
+ 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.
14
+
15
+ > **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.
16
+
17
+ ---
18
+
19
+ ## Why you'd want this
20
+
21
+ - 🩺 **One-call health.** `cluster_summary` gives node + pod totals and the unhealthy pods, so the agent starts triage with real data.
22
+ - 🔒 **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.
23
+ - 🧰 **The operations you actually do.** Pods, deployments, events, logs, node health, rollout-restart, scale, delete-pod.
24
+ - 🪶 **No bespoke backend.** Uses the standard Kubernetes API + your kubeconfig — nothing to deploy server-side.
25
+ - ✅ **Tested.** Pure logic is unit-tested with fakes; guard logic is tested against a mocked API. No cluster needed to run the suite.
26
+
27
+ ## Requirements
28
+
29
+ - A reachable cluster and a working **kubeconfig** (the same one `kubectl` uses), or run it in-cluster with a service account.
30
+ - Python 3.11+ (or just `uvx`).
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ uvx whitecapdata-dev # run directly
36
+ # or
37
+ pip install whitecapdata-dev # then run: whitecapdata-dev
38
+ ```
39
+
40
+ ### Claude Code
41
+
42
+ ```bash
43
+ claude mcp add homelab -- uvx whitecapdata-dev
44
+ ```
45
+
46
+ ### Claude Desktop / Cursor
47
+
48
+ ```jsonc
49
+ {
50
+ "mcpServers": {
51
+ "homelab": {
52
+ "command": "uvx",
53
+ "args": ["whitecapdata-dev"],
54
+ "env": {
55
+ "HOMELAB_MCP_MUTABLE_NAMESPACES": "default,apps,monitoring",
56
+ "HOMELAB_MCP_READONLY": "0"
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ## Tools
64
+
65
+ | Tool | Kind | Description |
66
+ | --- | --- | --- |
67
+ | `cluster_summary` | read | Node/pod health totals + unhealthy pods |
68
+ | `list_pods` | read | Pods (optionally one namespace), unhealthy first |
69
+ | `list_deployments` | read | Deployments with ready/desired replicas |
70
+ | `list_events` | read | Recent events, Warnings first |
71
+ | `pod_logs` | read | Tail a pod's logs |
72
+ | `node_health` | read | Per-node readiness, kubelet, capacity, pressure |
73
+ | `restart_deployment` | **write** | Rollout-restart (allowlisted namespaces) |
74
+ | `scale_deployment` | **write** | Scale to N replicas (0..max, allowlisted) |
75
+ | `delete_pod` | **write** | Delete a pod; its controller recreates it (allowlisted) |
76
+ | `server_info` | read | Effective config (context, read-only, allowlist) |
77
+
78
+ ## Configuration
79
+
80
+ | Variable | Default | Description |
81
+ | --- | --- | --- |
82
+ | `HOMELAB_MCP_CONTEXT` | current-context | kubeconfig context to use |
83
+ | `HOMELAB_MCP_READONLY` | `0` | `1`/`true` disables all mutating tools |
84
+ | `HOMELAB_MCP_MUTABLE_NAMESPACES` | `default,apps,monitoring,ci` | Namespaces mutations may touch; `*` = all |
85
+ | `HOMELAB_MCP_MAX_REPLICAS` | `10` | Upper bound for `scale_deployment` |
86
+
87
+ ## Safety model
88
+
89
+ 1. **Read-only switch** — `HOMELAB_MCP_READONLY=1` rejects every mutating tool up front.
90
+ 2. **Namespace allowlist** — mutating tools refuse any namespace not in `HOMELAB_MCP_MUTABLE_NAMESPACES` (default a homelab-friendly set; `*` opts into all).
91
+ 3. **Bounded scale** — `scale_deployment` clamps to `0..HOMELAB_MCP_MAX_REPLICAS`.
92
+
93
+ The cluster's own RBAC still applies on top — this server can only do what the kubeconfig identity is permitted to do.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ git clone https://github.com/Michael-WhiteCapData/WhiteCapData-Dev
99
+ cd WhiteCapData-Dev
100
+ uv pip install -e ".[dev]"
101
+ ruff check .
102
+ pytest # no cluster required — APIs are faked/mocked
103
+ ```
104
+
105
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
106
+
107
+ ## License
108
+
109
+ [MIT](LICENSE) © Michael Tierney
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "whitecapdata-dev"
7
+ version = "0.1.0"
8
+ description = "WhiteCapData-Dev — an MCP server to operate a k3s / Kubernetes cluster (health, logs, and guarded restart/scale/delete) straight from your AI agent."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Michael Tierney" }]
13
+ keywords = ["mcp", "model-context-protocol", "kubernetes", "k3s", "k8s", "homelab", "devops", "claude"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: System :: Systems Administration",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=1.2",
25
+ "kubernetes>=29",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Michael-WhiteCapData/WhiteCapData-Dev"
30
+ Repository = "https://github.com/Michael-WhiteCapData/WhiteCapData-Dev"
31
+ Issues = "https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/issues"
32
+
33
+ [project.scripts]
34
+ whitecapdata-dev = "homelab_mcp.server:main"
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/homelab_mcp"]
41
+
42
+ [tool.pytest.ini_options]
43
+ addopts = "-q"
44
+ testpaths = ["tests"]
45
+
46
+ [tool.ruff]
47
+ line-length = 110
48
+ target-version = "py311"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "SIM", "PLC"]
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
3
+ "name": "io.github.Michael-WhiteCapData/WhiteCapData-Dev",
4
+ "description": "Operate a k3s / Kubernetes cluster from an MCP client — health, logs, and guarded restart/scale/delete, safe by default.",
5
+ "repository": {
6
+ "url": "https://github.com/Michael-WhiteCapData/WhiteCapData-Dev",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "pypi",
13
+ "identifier": "whitecapdata-dev",
14
+ "version": "0.1.0",
15
+ "transport": { "type": "stdio" },
16
+ "environmentVariables": [
17
+ {
18
+ "name": "HOMELAB_MCP_CONTEXT",
19
+ "description": "kubeconfig context to use (defaults to current-context).",
20
+ "isRequired": false
21
+ },
22
+ {
23
+ "name": "HOMELAB_MCP_READONLY",
24
+ "description": "Set to 1/true to disable all mutating tools.",
25
+ "default": "0",
26
+ "isRequired": false
27
+ },
28
+ {
29
+ "name": "HOMELAB_MCP_MUTABLE_NAMESPACES",
30
+ "description": "Comma-separated namespaces mutations may touch; '*' allows all.",
31
+ "default": "default,apps,monitoring,ci",
32
+ "isRequired": false
33
+ },
34
+ {
35
+ "name": "HOMELAB_MCP_MAX_REPLICAS",
36
+ "description": "Upper bound accepted by scale_deployment.",
37
+ "default": "10",
38
+ "isRequired": false
39
+ }
40
+ ]
41
+ }
42
+ ]
43
+ }
@@ -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"
@@ -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
+ }
@@ -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]
@@ -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)"
@@ -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,46 @@
1
+ """Lightweight fakes that mimic the kubernetes client model attributes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace as NS
6
+
7
+
8
+ def make_pod(name, namespace="default", phase="Running", ready=True, restarts=0, node="n1"):
9
+ cs = NS(ready=ready, restart_count=restarts)
10
+ return NS(
11
+ metadata=NS(name=name, namespace=namespace),
12
+ status=NS(phase=phase, container_statuses=[cs]),
13
+ spec=NS(node_name=node),
14
+ )
15
+
16
+
17
+ def make_node(name, ready=True, kubelet="v1.30.0", cpu="4", memory="8Gi", pressures=None):
18
+ conditions = [NS(type="Ready", status="True" if ready else "False")]
19
+ for p, status in (pressures or {}).items():
20
+ conditions.append(NS(type=p, status=status))
21
+ return NS(
22
+ metadata=NS(name=name),
23
+ status=NS(
24
+ conditions=conditions,
25
+ node_info=NS(kubelet_version=kubelet),
26
+ capacity={"cpu": cpu, "memory": memory},
27
+ ),
28
+ )
29
+
30
+
31
+ def make_deployment(name, namespace="default", replicas=3, ready=3):
32
+ return NS(
33
+ metadata=NS(name=name, namespace=namespace),
34
+ spec=NS(replicas=replicas),
35
+ status=NS(ready_replicas=ready),
36
+ )
37
+
38
+
39
+ def make_event(reason, type_="Warning", kind="Pod", name="p", namespace="default", message="msg"):
40
+ return NS(
41
+ type=type_,
42
+ reason=reason,
43
+ involved_object=NS(kind=kind, name=name),
44
+ metadata=NS(namespace=namespace),
45
+ message=message,
46
+ )
@@ -0,0 +1,35 @@
1
+ from homelab_mcp.config import DEFAULT_MUTABLE_NAMESPACES, Config
2
+
3
+
4
+ def test_defaults():
5
+ cfg = Config.from_env(env={})
6
+ assert cfg.read_only is False
7
+ assert cfg.context is None
8
+ assert cfg.mutable_namespaces == DEFAULT_MUTABLE_NAMESPACES
9
+ assert cfg.max_replicas == 10
10
+
11
+
12
+ def test_read_only_parsing():
13
+ assert Config.from_env(env={"HOMELAB_MCP_READONLY": "true"}).read_only is True
14
+ assert Config.from_env(env={"HOMELAB_MCP_READONLY": "1"}).read_only is True
15
+ assert Config.from_env(env={"HOMELAB_MCP_READONLY": "no"}).read_only is False
16
+
17
+
18
+ def test_namespace_allowlist_explicit():
19
+ cfg = Config.from_env(env={"HOMELAB_MCP_MUTABLE_NAMESPACES": "apps, ci"})
20
+ assert cfg.mutable_namespaces == ("apps", "ci")
21
+ assert cfg.namespace_allowed("apps") is True
22
+ assert cfg.namespace_allowed("kube-system") is False
23
+
24
+
25
+ def test_namespace_wildcard_allows_all():
26
+ cfg = Config.from_env(env={"HOMELAB_MCP_MUTABLE_NAMESPACES": "*"})
27
+ assert cfg.mutable_namespaces == ()
28
+ assert cfg.namespace_allowed("anything") is True
29
+
30
+
31
+ def test_as_dict_shows_star_for_empty():
32
+ cfg = Config.from_env(env={"HOMELAB_MCP_MUTABLE_NAMESPACES": "*", "HOMELAB_MCP_CONTEXT": "homelab"})
33
+ d = cfg.as_dict()
34
+ assert d["mutable_namespaces"] == ["*"]
35
+ assert d["context"] == "homelab"
@@ -0,0 +1,56 @@
1
+ from conftest import make_deployment, make_event, make_node, make_pod
2
+
3
+ from homelab_mcp import format as fmt
4
+
5
+
6
+ def test_pod_health():
7
+ assert fmt.pod_is_healthy(make_pod("a")) is True
8
+ assert fmt.pod_is_healthy(make_pod("b", phase="Pending")) is False
9
+ assert fmt.pod_is_healthy(make_pod("c", ready=False)) is False
10
+ assert fmt.pod_is_healthy(make_pod("d", phase="Succeeded", ready=False)) is True
11
+
12
+
13
+ def test_pod_rows_sort_unhealthy_first():
14
+ pods = [make_pod("ok"), make_pod("bad", phase="CrashLoopBackOff", ready=False)]
15
+ rows = fmt.pod_rows(pods)
16
+ assert rows[0]["name"] == "bad" and rows[0]["ready"] is False
17
+ assert rows[1]["name"] == "ok"
18
+
19
+
20
+ def test_pod_row_aggregates_restarts():
21
+ assert fmt.pod_row(make_pod("x", restarts=5))["restarts"] == 5
22
+
23
+
24
+ def test_cluster_summary_counts():
25
+ nodes = [make_node("n1"), make_node("n2", ready=False)]
26
+ pods = [make_pod("a"), make_pod("b", phase="Pending", ready=False)]
27
+ s = fmt.cluster_summary(nodes, pods)
28
+ assert s["nodes"] == {"total": 2, "ready": 1}
29
+ assert s["pods"]["total"] == 2
30
+ assert s["pods"]["unhealthy"] == 1
31
+ assert len(s["unhealthy_pods"]) == 1
32
+
33
+
34
+ def test_deployment_rows():
35
+ rows = fmt.deployment_rows([make_deployment("web", ready=2, replicas=3)])
36
+ assert rows[0]["ready_replicas"] == 2
37
+ assert rows[0]["desired_replicas"] == 3
38
+ assert rows[0]["available"] is False
39
+
40
+
41
+ def test_node_row_reports_pressure():
42
+ node = make_node("n1", pressures={"MemoryPressure": "True", "DiskPressure": "False"})
43
+ row = fmt.node_row(node)
44
+ assert row["pressure"] == ["MemoryPressure"]
45
+ assert row["ready"] is True
46
+
47
+
48
+ def test_event_rows_warning_first_and_limited():
49
+ events = [
50
+ make_event("Scheduled", type_="Normal"),
51
+ make_event("BackOff", type_="Warning"),
52
+ make_event("Pulled", type_="Normal"),
53
+ ]
54
+ rows = fmt.event_rows(events, limit=2)
55
+ assert len(rows) == 2
56
+ assert rows[0]["type"] == "Warning"
@@ -0,0 +1,70 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ import pytest
4
+ from conftest import make_node, make_pod
5
+
6
+ from homelab_mcp.config import Config
7
+ from homelab_mcp.kube import HomelabMCPError, KubeClient
8
+
9
+
10
+ def _client(config=None, core=None, apps=None):
11
+ return KubeClient(config or Config(), core_v1=core or MagicMock(), apps_v1=apps or MagicMock())
12
+
13
+
14
+ def test_cluster_summary_calls_apis_and_shapes():
15
+ core = MagicMock()
16
+ core.list_node.return_value.items = [make_node("n1")]
17
+ core.list_pod_for_all_namespaces.return_value.items = [make_pod("a")]
18
+ summary = _client(core=core).cluster_summary()
19
+ assert summary["nodes"]["ready"] == 1
20
+ core.list_node.assert_called_once()
21
+
22
+
23
+ def test_list_pods_uses_namespaced_call_when_given():
24
+ core = MagicMock()
25
+ core.list_namespaced_pod.return_value.items = [make_pod("a", namespace="apps")]
26
+ _client(core=core).list_pods("apps")
27
+ core.list_namespaced_pod.assert_called_once_with("apps")
28
+ core.list_pod_for_all_namespaces.assert_not_called()
29
+
30
+
31
+ def test_read_only_blocks_mutations_before_api_call():
32
+ apps = MagicMock()
33
+ client = _client(Config(read_only=True), apps=apps)
34
+ with pytest.raises(HomelabMCPError, match="read-only"):
35
+ client.scale_deployment("apps", "web", 2)
36
+ apps.patch_namespaced_deployment_scale.assert_not_called()
37
+
38
+
39
+ def test_namespace_not_in_allowlist_is_blocked():
40
+ apps = MagicMock()
41
+ client = _client(Config(mutable_namespaces=("apps",)), apps=apps)
42
+ with pytest.raises(HomelabMCPError, match="not in the mutable allowlist"):
43
+ client.delete_pod("kube-system", "coredns-x")
44
+ # delete_pod is on core, but the guard runs first — ensure no apps call either
45
+ apps.assert_not_called()
46
+
47
+
48
+ def test_scale_rejects_out_of_range():
49
+ client = _client(Config(mutable_namespaces=("apps",), max_replicas=10))
50
+ with pytest.raises(HomelabMCPError, match="between 0 and 10"):
51
+ client.scale_deployment("apps", "web", 99)
52
+
53
+
54
+ def test_allowed_scale_calls_api():
55
+ apps = MagicMock()
56
+ client = _client(Config(mutable_namespaces=("apps",)), apps=apps)
57
+ msg = client.scale_deployment("apps", "web", 3)
58
+ apps.patch_namespaced_deployment_scale.assert_called_once()
59
+ assert "3 replicas" in msg
60
+
61
+
62
+ def test_restart_sets_annotation_and_returns_message():
63
+ apps = MagicMock()
64
+ client = _client(Config(mutable_namespaces=("apps",)), apps=apps)
65
+ msg = client.restart_deployment("apps", "web")
66
+ args, kwargs = apps.patch_namespaced_deployment.call_args
67
+ body = kwargs["body"]
68
+ ann = body["spec"]["template"]["metadata"]["annotations"]
69
+ assert "kubectl.kubernetes.io/restartedAt" in ann
70
+ assert "restarted deployment apps/web" in msg
@@ -0,0 +1,49 @@
1
+ """Tool layer delegates to the client and serializes JSON; client is stubbed."""
2
+
3
+ import json
4
+
5
+ from homelab_mcp import server
6
+ from homelab_mcp.config import Config
7
+
8
+
9
+ class StubClient:
10
+ def __init__(self):
11
+ self.config = Config()
12
+ self.calls = []
13
+
14
+ def cluster_summary(self):
15
+ return {"nodes": {"total": 1, "ready": 1}, "pods": {"total": 0}, "unhealthy_pods": []}
16
+
17
+ def list_pods(self, namespace=""):
18
+ self.calls.append(("list_pods", namespace))
19
+ return [{"name": "a", "ready": True}]
20
+
21
+ def restart_deployment(self, namespace, name):
22
+ self.calls.append(("restart", namespace, name))
23
+ return f"restarted {namespace}/{name}"
24
+
25
+
26
+ def test_cluster_summary_tool_returns_json():
27
+ server.set_client(StubClient())
28
+ out = json.loads(server.cluster_summary())
29
+ assert out["nodes"]["ready"] == 1
30
+
31
+
32
+ def test_list_pods_tool_delegates_namespace():
33
+ stub = StubClient()
34
+ server.set_client(stub)
35
+ json.loads(server.list_pods("apps"))
36
+ assert ("list_pods", "apps") in stub.calls
37
+
38
+
39
+ def test_restart_tool_delegates():
40
+ stub = StubClient()
41
+ server.set_client(stub)
42
+ assert server.restart_deployment("apps", "web") == "restarted apps/web"
43
+ assert ("restart", "apps", "web") in stub.calls
44
+
45
+
46
+ def test_server_info_tool_serializes_config():
47
+ server.set_client(StubClient())
48
+ info = json.loads(server.server_info())
49
+ assert "mutable_namespaces" in info and "read_only" in info