Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ jobs:
- name: ruff installieren
run: pip install ruff

- name: ruff auf src/ ausführen
run: ruff check src/
- name: ruff auf src/ und tests/ ausführen
run: ruff check src/ tests/

# --- 2) Tests: Unit + Integration via pytest -------------------------------
test:
Expand All @@ -52,10 +52,21 @@ jobs:
- name: Dependencies installieren (inkl. Test-Extras)
run: uv sync --all-extras

- name: pytest ausführen (unit + integration)
- name: pytest ausführen (unit + integration, mit Coverage)
# PYTHONPATH leeren, um Interpreter-Hijacking durch System-Venvs zu verhindern.
# Coverage ist report-only (kein --cov-fail-under-Gate) — eine Schwelle
# kommt, sobald eine stabile Baseline steht. addopts in pyproject.toml
# erzeugt term-missing + coverage.xml. Deps kamen via uv sync --all-extras.
run: env -u PYTHONPATH uv run pytest tests/unit tests/integration -v

- name: Coverage-Artefakt hochladen
uses: actions/upload-artifact@v4
with:
name: coverage-xml
path: coverage.xml
if-no-files-found: error
retention-days: 14

# --- 3) Build: uv build + Wheel-Artefakt-Upload ---------------------------
build:
name: Build (uv build)
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ tmp/
.ruff_cache/
.tmp/

# Coverage / pytest-cov Artefakte
.coverage
coverage.xml
*.cover
htmlcov/

# Hermes Agent lokale Daten (Pläne, Cache — nicht fürs Repo)
.hermes/

Expand Down
36 changes: 25 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ Credentials are **always** referenced as `${ENV_VAR}` — never stored here. Cop
| `memory` | npx (`@modelcontextprotocol/server-memory`) | Persistent knowledge graph across sessions | `MEMORY_FILE_PATH` (default `~/.agent-memory/memory.json`) |
| `puppeteer` | npx (`@modelcontextprotocol/server-puppeteer`) | Headless browser / web automation | — |
| `sequential-thinking` | npx (`@modelcontextprotocol/server-sequential-thinking`) | Structured multi-step reasoning | — |
| `basti-tools` | `uv run mcp-server-basti` (FastMCP/stdio, local) | Local system-status tools (`get_system_status`, `echo_tool`, `get_repo_info`) | — |
| `basti-tools` | `uv run mcp-server-basti` (FastMCP/stdio, local) | Local read-only system diagnostics (`get_system_status`, `get_disk_status`, `get_gpu_status`, `get_memory_status`, `get_failed_units`, `get_kernel_warnings`, `get_boot_timing`, `get_power_profile`, `get_firewall_state`, `get_repo_info`, `echo_tool`) | — (sudoers rule for `get_firewall_state`, see `docs/mcp-server/SUDOERS_SETUP.md`) |

#### Gmail + Google Calendar — Quick OAuth2 Setup

Expand Down Expand Up @@ -342,11 +342,23 @@ kein Netzwerk-Listener, keine externen Secrets in der Default-Konfiguration.

### Tools

Alle Tools advertise `readOnlyHint=True` (keine Mutationen). Strukturierte Rückgaben
sind TypedDicts (Schema-Ableitung via FastMCP); `echo_tool`/`get_kernel_warnings`
geben rohen Text zurück.

| Tool | Zweck |
|---|---|
| `get_system_status` | Führt `uptime` aus und gibt den Systemstatus zurück (lokal, ohne Netzwerk). |
| `echo_tool` | Smoke-Test-Tool — gibt Eingabe unverändert zurück. Nützlich zum Verifizieren, dass der Server überhaupt antwortet. |
| `get_repo_info` | Liefert Git-Branch und letzten Commit des Server-Repos. |
| `get_system_status` | `uptime`-Output (lokal, ohne Netzwerk). |
| `echo_tool` | Smoke-Test — gibt Eingabe unverändert zurück. |
| `get_repo_info` | Git-Branch + letzter Commit des Server-Repos (strukturiert `{branch,last_commit,detached}`). |
| `get_disk_status` | `df -h` über alle Dateisysteme. |
| `get_gpu_status` | `nvidia-smi` Treiber/Temp/Auslastung/VRAM + Power-Limits. |
| `get_memory_status` | `free -h` + `zramctl` + `swapon --show`. |
| `get_failed_units` | Fehlgeschlagene systemd-Units (`systemctl --failed`). |
| `get_kernel_warnings` | `journalctl -b -p warning` (roher Text). |
| `get_boot_timing` | `systemd-analyze blame` + `critical-chain`. |
| `get_power_profile` | `powerprofilesctl get`. |
| `get_firewall_state` | `sudo -n ufw status verbose` + `ss -tlnp` — **benötigt eine NOPASSWD-sudoers-Regel** (siehe [`docs/mcp-server/SUDOERS_SETUP.md`](docs/mcp-server/SUDOERS_SETUP.md)); ohne sie degradiert der Aufruf sauber zu einem `ToolError`. |

### Installation

Expand Down Expand Up @@ -374,7 +386,7 @@ eingetragen. Der `github`-Eintrag bleibt unverändert daneben bestehen:
"command": "uv",
"args": [
"run",
"--directory", "/home/bratan/ZCodeProject/my-agent-tools",
"--directory", "${CLAUDE_PROJECT_ROOT}",
"mcp-server-basti"
]
}
Expand All @@ -383,23 +395,25 @@ eingetragen. Der `github`-Eintrag bleibt unverändert daneben bestehen:
```

Beim Plugin-Start wird der Server automatisch hochgefahren — keine weiteren
Schritte erforderlich.
Schritte erforderlich. Für `get_firewall_state` muss additionally die
sudoers-Regel aus [`docs/mcp-server/SUDOERS_SETUP.md`](docs/mcp-server/SUDOERS_SETUP.md)
installiert sein; alle anderen 10 Tools laufen ohne Voraussetzung.

### Tests ausführen

```bash
cd /home/bratan/ZCodeProject/my-agent-tools
uv sync --all-extras
uv run pytest tests/unit tests/integration -v
uv run --extra dev pytest tests/unit tests/integration -v
```

### Architektur

- **Transport:** stdio (kein HTTP, kein Port). Der Parent-Prozess (Claude Code)
startet den Server als Subprozess und spricht JSON-RPC über stdin/stdout.
- **Framework:** [FastMCP](https://github.com/jlowin/fastmcp) auf Basis von
[`mcp`](https://pypi.org/project/mcp/) — deklarative Tool-Definition via
Python-Decorators.
- **Framework:** [FastMCP](https://github.com/jlowin/fastmcp) (standalone `>=3.0`,
nicht der `mcp[cli]`-Shim) auf Basis von [`mcp`](https://pypi.org/project/mcp/) —
deklarative Tool-Definition via Python-Decorators mit `tags`, `annotations`
(`readOnlyHint=True`), `timeout` und per-TypedDict abgeleiteten `output_schema`s.
- **Sandboxing:** läuft mit den Rechten des aufrufenden Users. Schreibt nur in
Bereiche, in denen der User schreiben darf.
- **CI:** `.github/workflows/ci.yml` führt auf jedem Push/PR nach `main` Lint
Expand Down
78 changes: 78 additions & 0 deletions docs/mcp-server/SUDOERS_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Sudoers-Setup für `get_firewall_state`

Das MCP-Tool `get_firewall_state` liest den UFW-Status und die lauschenden
TCP-Ports ab. Beide Kommandos benötigen root-Rechte:

- `ufw status verbose` — nur root sieht die vollständige Regel-Liste.
- `ss -tlnp` — nur root sieht die zu den Sockets gehörigen Prozesse.

Statt dem MCP-Server broad sudo zu geben, gibt es eine **eng begrenzte
NOPASSWD-Regel**, die genau diese beiden Read-Only-Kommandos für den
Workstation-User erlaubt.

## Voraussetzung: absolute Pfade verifizieren

`sudoers` matcht auf den exakten argv[0], den das Tool aufruft. Auf dieser
Workstation (Zorin OS 18.1) sind das:

```bash
command -v ufw ss
# /usr/sbin/ufw
# /usr/bin/ss
```

Falls die Pfade auf einer anderen Distribution abweichen, passe sowohl das
Sudoers-Snippet als auch die Konstanten `_UFW_BIN` / `_SS_BIN` in
`src/mcp_server_basti/server.py` an.

## Sudoers-Snippet installieren

Datei `/etc/sudoers.d/mcp-basti-firewall` (mode `0440`, root:root) anlegen:

```sudoers
# mcp-server-basti: erlaubt passwortlose, read-only Firewall-Inspektion.
# Installiert von Basti; Datei löschen, um get_firewall_state zu deaktivieren.
Cmnd_Alias MCP_BASTI_FW = /usr/sbin/ufw status verbose, /usr/bin/ss -tlnp
bratan ALL=(root) NOPASSWD: MCP_BASTI_FW
```

Installieren + validieren:

```bash
sudo install -m 0440 -o root -g root /dev/stdin /etc/sudoers.d/mcp-basti-firewall <<'EOF'
# mcp-server-basti: erlaubt passwortlose, read-only Firewall-Inspektion.
Cmnd_Alias MCP_BASTI_FW = /usr/sbin/ufw status verbose, /usr/bin/ss -tlnp
bratan ALL=(root) NOPASSWD: MCP_BASTI_FW
EOF
sudo visudo -c # Syntax-Check; muss "parsed OK" melden
```

> Den User (`bratan`) und ggf. die Pfade an die eigene Box anpassen.

## Verifizieren

```bash
sudo -n /usr/sbin/ufw status verbose # kein Passwort-Prompt → Regel greift
sudo -n /usr/bin/ss -tlnp
```

Danach liefert das MCP-Tool strukturierte `{ufw, listening_ports}` zurück.

## Degradation ohne die Regel

Ist die sudoers-Datei nicht installiert (oder die Pfade stimmen nicht), ruft
das Tool `sudo -n` auf. `sudo -n` scheitert **sofort**, statt ein Passwort zu
verlangen — der Tool fängt das ab und raiset einen `ToolError`, der auf diese
Datei verweist. Es gibt **keinen** stillen Partial-Result: der Contract ist
„Firewall-State oder ein klarer Fehler".

## Sicherheitshinweise

- Die Regel erlaubt **nur** die zwei genannten Read-Only-Kommandos mit exakten
Argumenten — kein `ufw` ohne args, kein `ss` mit anderen Flags, keine Shell.
- `Cmnd_Alias` mit Komma-getrennten exakten Kommandos ist der sicherste
sudoers-Mechanismus (kein Wildcard-Matching, das ausgenutzt werden könnte).
- Datei mode `0440` und root:root sind zwingend (sonst ignoriert sudo sie).
- Entferne die Datei, um das Tool zu deaktivieren — der Server advertised
`get_firewall_state` weiterhin (Discovery ist statisch), aber Aufrufe
degradieren sauber zu `ToolError`.
60 changes: 40 additions & 20 deletions docs/mcp-server/TOOL_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@

## Tools

Alle Tools advertise `readOnlyHint=True` (keine Mutationen) und — außer `echo_tool` —
`tags={"system","read-only"}`. Strukturierte Rückgaben sind TypedDicts aus
`src/mcp_server_basti/schemas.py`; FastMCP leitet das JSON-Schema automatisch ab und
liefert das Ergebnis als `structuredContent`.
Comment on lines +5 to +8

| Tool | Parameter | Rückgabetyp | Beschreibung | Side-Effect |
|------|-----------|-------------|--------------|-------------|
| `get_system_status` | keine | `str` (uptime-Output) | Führt `uptime` aus und gibt den stdout zurück. | read (Subprocess, keine Mutation) |
| `echo_tool` | `text: str` (required) | `str` | Gibt den übergebenen Text unverändert zurück. Health-Check-Tool. | none |
| `get_repo_info` | keine | `str` | Git-Branch und letzter Commit des Server-Repos (`DEFAULT_REPO_PATH`). | read (git-Subprocess, keine Mutation) |
| `get_system_status` | keine | `SystemStatus{uptime: str}` | Führt `uptime` aus. | read (Subprocess) |
| `echo_tool` | `text: str` (required) | `str` (roh) | Health-Check, gibt Text unverändert zurück. | none |
| `get_repo_info` | keine | `RepoInfo{branch,last_commit,detached}` | Git-Branch + letzter Commit des Server-Repos (`DEFAULT_REPO_PATH`). | read (git-Subprocess) |
| `get_disk_status` | keine | `DiskStatus{filesystems: [Filesystem]}` | `df -h --output=...` über alle Dateisysteme. | read |
| `get_gpu_status` | keine | `GpuStatus{...}` | `nvidia-smi` Treiber/Temp/Auslastung/VRAM + `-q -d POWER` (power_* evtl. `None`). | read |
| `get_memory_status` | keine | `MemoryStatus{free,zram,swaps}` | `free -h` + `zramctl` + `swapon --show` (rohe Text-Blöcke). | read |
| `get_failed_units` | keine | `FailedUnits{failed: [str], raw: str}` | `systemctl --failed` (gefiltert auf echte Unit-Namen). | read |
| `get_kernel_warnings` | keine | `str` (roh) | `journalctl -b -p warning` (bewusst unparseiert). | read |
| `get_boot_timing` | keine | `BootTiming{blame: [BlameEntry], critical_chain: str}` | `systemd-analyze blame` + `critical-chain`. | read |
| `get_power_profile` | keine | `PowerProfile{profile: str}` | `powerprofilesctl get`. | read |
| `get_firewall_state` | keine | `FirewallState{ufw, listening_ports}` | `sudo -n ufw status verbose` + `ss -tlnp`; benötigt NOPASSWD-sudoers-Regel (siehe `SUDOERS_SETUP.md`). | read (sudo, eng begrenzt) |

## Beispiele

Expand All @@ -19,9 +32,7 @@
04:24:40 up 2:46, 1 user, load average: 9,64, 8,19, 6,85
```

> Format hängt vom Betriebssystem ab. Der Server gibt den rohen `uptime`-stdout zurück ohne weitere Formatierung.

**Fehlerfall:** Wenn `uptime` nicht gefunden wird oder mit Exit-Code ≠ 0 terminiert, gibt das Tool einen strukturierten Fehler zurück (`isError: true`).
> Format hängt vom Betriebssystem ab. Der Server gibt den rohen `uptime`-stdout zurück.

### echo_tool

Expand All @@ -30,30 +41,39 @@
{"text": "hello"}
```

**Output:**
```
hello
```

**Fehlerfall:** FastMCP validiert via Pydantic, dass `text` ein String ist. Fehlt der Parameter → `McpError` auf Client-Seite.
**Output:** `hello` (roher String, beliebiger Text inkl. Leer/Unicode round-trip-t).

### get_repo_info

**Input:** keine Parameter

**Output (beispielhaft):**
```
Branch: main
Letzter Commit: 634ff36 Merge pull request #1 from Toqsick/integrate/zcode-routing
**Output (beispielhaft, attached):**
```json
{"branch": "main", "last_commit": "634ff36 Merge pull request #1 ...", "detached": false}
```

> Das Tool ruft `git rev-parse --abbrev-ref HEAD` und `git log -1 --oneline` für das Server-Repo (`DEFAULT_REPO_PATH` in `server.py`) auf. Es liefert **nur** Branch und Commit — kein Remote, keine Tags.
**Detached HEAD:** Im detached-HEAD-State scheitert `git symbolic-ref --short HEAD`
(non-zero); das Tool fällt auf `git rev-parse --short HEAD` zurück und liefert
`branch: "detached@<short-sha>"`, `detached: true`.

> Ruft `git symbolic-ref --short HEAD` (mit `rev-parse --short`-Fallback) und
> `git log -1 --oneline` für das Server-Repo auf. Kein Remote, keine Tags.

### get_firewall_state

**Voraussetzung:** installierte NOPASSWD-sudoers-Regel — siehe `SUDOERS_SETUP.md`.
Ohne die Regel degradiert der Aufruf zu einem `ToolError`, der auf jene Datei verweist
(kein stiller Partial-Result). Das Tool ist dennoch immer advertised (Discovery ist
statisch); nur die *Ausführung* benötigt die Regel.

**Fehlerfall:** Wenn das Verzeichnis kein Git-Repo ist oder `git` fehlschlägt, gibt das Tool einen strukturierten Fehler zurück (`isError: true`).
**Output (beispielhaft):** rohe Text-Blöcke für UFW-Status und `ss -tlnp`.

## Fehlerformat

Tool-Errors nutzen `fastmcp.tools.base.ToolResult` mit `is_error=True`:
Tool-Errors werden als **`ToolError`-Exception** signalisiert. Der mcp-lowlevel-Pfad
baut die Wire-Antwort in `mcp/server/lowlevel/server.py`; nur Exceptions werden zu
`CallToolResult(isError=True)`. Normale Return-Werte werden pauschal mit
`isError=False` gewrappt — deshalb raise statt `return ToolResult(is_error=True)`.

```json
{
Expand All @@ -66,4 +86,4 @@ Tool-Errors nutzen `fastmcp.tools.base.ToolResult` mit `is_error=True`:

Der Server ist für die Integration mit dem bestehenden `skill-mcp-router` vorbereitet, aber **noch nicht aktiv verdrahtet**. Die `routing/registry/registry.json` hat ein `mcp_server`-Feld pro Skill, in das `"mcp-server-basti"` eingetragen werden kann, um Skill-Intents zu diesen Tools aufzulösen.

**Status:** Derzeit nutzt kein Skill in der Registry den Server `mcp-server-basti`. Um ihn zu aktivieren, müssten Skills mit passenden Intents angelegt und in der Registry registriert werden. Siehe `routing/registry/registry.json` für das Format.
**Status:** Derzeit nutzt kein Skill in der Registry den Server `mcp-server-basti`. Um ihn zu aktivieren, müssten Skills mit passenden Intents angelegt und in der Registry registriert werden. Siehe `routing/registry/registry.json` für das Format.
35 changes: 29 additions & 6 deletions docs/mcp-server/USAGE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# MCP-Server Basti – Nutzung

`mcp-server-basti` ist ein privater MCP-Server für drei Aufgaben: Systemstatus abfragen, Text echoen und Repository-Informationen lesen. Der Server nutzt **stdio**; der MCP-Client startet ihn als lokalen Unterprozess und kommuniziert über stdin/stdout.
`mcp-server-basti` ist ein privater MCP-Server mit **11 read-only Werkzeugen** für
lokale System-Diagnose: Systemstatus, Disk/GPU/Memory, fehlgeschlagene systemd-Units,
Kernel-Warnings, Boot-Timing, Power-Profil, Firewall-Status sowie ein Repo-Info- und
ein Echo-Tool. Der Server nutzt **stdio**; der MCP-Client startet ihn als lokalen
Unterprozess und kommuniziert über stdin/stdout. Strukturierte Rückgaben sind
TypedDicts (Schema-Ableitung via FastMCP); `echo_tool` gibt rohen Text zurück.

## Voraussetzungen

Expand Down Expand Up @@ -86,13 +91,20 @@ Details zur Hermes-MCP-Konfiguration: `hermes mcp` CLI oder `hermes setup tools`
## Tests ausführen

```bash
uv run pytest tests/unit tests/integration -v
uv run --extra dev pytest tests/unit tests/integration -v
```

Linting:

```bash
ruff check src/
ruff check src/ tests/
```

Der lokale Firewall-Integrationstest ist standardmäßig übersprungen (benötigt die
installierte sudoers-Regel). Aktivieren via `BASTI_FW_TESTS=1`:

```bash
BASTI_FW_TESTS=1 uv run --extra dev pytest tests/integration/test_firewall_integration.py -v
```

## Logging analysieren
Expand All @@ -113,12 +125,23 @@ MCP-Client (Claude/Cursor/Hermes)
│ JSON-RPC über stdin/stdout (stdio)
▼
mcp-server-basti (stderr = JSON-Logs)
├── get_system_status (uptime-Subprocess, read-only)
├── echo_tool (reine Eingabe → Ausgabe, no side-effects)
└── get_repo_info (git branch + commit, read-only)
├── get_system_status (uptime, read-only)
├── echo_tool (Smoke-Test, no side-effects)
├── get_repo_info (git symbolic-ref + log, read-only)
├── get_disk_status (df -h, read-only)
├── get_gpu_status (nvidia-smi + -q -d POWER, read-only)
├── get_memory_status (free -h + zramctl + swapon, read-only)
├── get_failed_units (systemctl --failed, read-only)
├── get_kernel_warnings (journalctl -b -p warning, read-only)
├── get_boot_timing (systemd-analyze blame/critical-chain, read-only)
├── get_power_profile (powerprofilesctl get, read-only)
└── get_firewall_state (sudo -n ufw status + ss -tlnp, read-only; sudoers nötig)
```

Der Client startet pro Konfiguration einen Prozess. Tool-Aufrufe bleiben innerhalb dieses Prozesses.
Alle Tools advertise `readOnlyHint=True`. `get_firewall_state` benötigt eine
NOPASSWD-sudoers-Regel (siehe `SUDOERS_SETUP.md`); ohne sie degradiert es sauber zu
einem `ToolError`.

## Troubleshooting

Expand Down
19 changes: 17 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ name = "mcp-server-basti"
version = "0.1.0"
description = "Privater lokaler stdio-MCP-Server mit FastMCP"
requires-python = ">=3.11"
dependencies = ["mcp[cli]>=1.0", "fastmcp>=2.0"]
dependencies = ["fastmcp>=3.0", "typing_extensions>=4.12"]

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=4.1", "ruff>=0.6"]

[project.scripts]
mcp-server-basti = "mcp_server_basti.server:main"
Expand All @@ -22,3 +22,18 @@ packages = ["src/mcp_server_basti"]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "--cov=src/mcp_server_basti --cov-report=term-missing --cov-report=xml"

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
# select (nicht extend-select) ersetzt das ruff-Default-Set deterministisch;
# ISC/SIM/etc. bleiben bewusst aus.
select = ["E", "F", "W", "I", "UP", "B"]

[tool.ruff.lint.per-file-ignores]
# Test-Dateien enthalten häufig lange Kommando-Strings/fixture-Dicts, die
# nicht umbrechbar sind. Ein Coverage-Gate kommt, sobald eine Baseline steht.
"tests/**" = ["E501"]
Loading
Loading