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.
- whitecapdata_dev-0.1.0/.github/workflows/ci.yml +31 -0
- whitecapdata_dev-0.1.0/.github/workflows/release.yml +36 -0
- whitecapdata_dev-0.1.0/.gitignore +21 -0
- whitecapdata_dev-0.1.0/CHANGELOG.md +21 -0
- whitecapdata_dev-0.1.0/CONTRIBUTING.md +33 -0
- whitecapdata_dev-0.1.0/LICENSE +21 -0
- whitecapdata_dev-0.1.0/PKG-INFO +136 -0
- whitecapdata_dev-0.1.0/README.md +109 -0
- whitecapdata_dev-0.1.0/pyproject.toml +51 -0
- whitecapdata_dev-0.1.0/server.json +43 -0
- whitecapdata_dev-0.1.0/src/homelab_mcp/__init__.py +12 -0
- whitecapdata_dev-0.1.0/src/homelab_mcp/config.py +54 -0
- whitecapdata_dev-0.1.0/src/homelab_mcp/format.py +125 -0
- whitecapdata_dev-0.1.0/src/homelab_mcp/kube.py +118 -0
- whitecapdata_dev-0.1.0/src/homelab_mcp/server.py +113 -0
- whitecapdata_dev-0.1.0/tests/conftest.py +46 -0
- whitecapdata_dev-0.1.0/tests/test_config.py +35 -0
- whitecapdata_dev-0.1.0/tests/test_format.py +56 -0
- whitecapdata_dev-0.1.0/tests/test_kube.py +70 -0
- whitecapdata_dev-0.1.0/tests/test_server_tools.py +49 -0
|
@@ -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
|
+
[](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,109 @@
|
|
|
1
|
+
# WhiteCapData-Dev
|
|
2
|
+
|
|
3
|
+
**Operate a k3s / Kubernetes cluster straight from your AI agent — safe by default.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/Michael-WhiteCapData/WhiteCapData-Dev/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/whitecapdata-dev/)
|
|
7
|
+
[](https://www.python.org/)
|
|
8
|
+
[](https://modelcontextprotocol.io/)
|
|
9
|
+
[](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
|