Werkstatt · Kapitel 5 · 13. August 2026

Tools via MCP

Dev-Fassung mit Code-Walks — geprüft gegen wintermute@47ec498 (Stand 2026-07-03).

TL;DR: Ein Agent ohne Werkzeuge ist ein Chatbot. MCP (Model Context Protocol) ist der offene Standard, mit dem du Werkzeuge sauber an LLMs anbindest. Wintermute trennt streng: der Agent kennt nur einen einzigen Endpunkt (das MCP-Gateway), hinter dem alle Werkzeuge - Dateisystem, Host-Projekte, GitHub, IMAP, Web-Suche - gebündelt und sandboxed sind. Diese eine Trennlinie ist die wichtigste Sicherheitsentscheidung im ganzen Stack.

Das Problem

Ein LLM kann reden. Es kann nicht:

  • Eine Datei lesen
  • Eine E-Mail abrufen
  • Ein GitHub-Issue öffnen
  • Den aktuellen Wetterbericht prüfen
  • In deinem Kalender nachschauen

Werkzeuge sind die Brücke vom Sprachmodell zur echten Welt. Ohne sie ist der Agent ein eloquenter Glasballwerfer. Mit ihnen kann er Aufgaben erledigen, statt sie nur zu beschreiben.

Das eigentliche Problem ist nicht "wie binde ich Tools an?", sondern "wie binde ich Tools an, ohne dass jeder neue Werkzeug- Wunsch eine neue Sicherheits-Katastrophe wird?":

  • Wer gibt dem Werkzeug seine Berechtigungen?
  • Was passiert wenn ein halluzinierter Tool-Call eine unbeabsichtigte Aktion auslöst?
  • Wie tauscht man Werkzeuge aus, ohne das Modell neu zu trainieren?
  • Wie verhindert man dass eine bösartige Web-Seite oder eine Prompt-Injection den Agenten dazu bringt, Dateien zu löschen?

Bis Ende 2024 war die Antwort meistens "jeder bastelt sein eigenes Tool-Format pro Anbieter". Anthropic hatte ein, OpenAI ein anderes, lokale Modelle eins für sich. Das hat sich mit MCP geändert.

Unsere Lösung

MCP (Model Context Protocol) ist ein offener Standard von Anthropic, der das Interface zwischen LLMs und Werkzeugen vereinheitlicht. Stell es dir vor wie LSP für KI-Werkzeuge: genauso wie das Language Server Protocol vereinheitlicht hat, wie Editoren mit Sprach-Servern reden, vereinheitlicht MCP, wie Agenten mit Werkzeug-Servern reden.

Wintermutes Lösung ist eine einzige Gateway-Schicht, hinter der alle Werkzeuge liegen:

flowchart LR
    A["<b>Agent-Core</b><br/>weiß: es gibt einen Gateway<br/>weiß NICHT: was dahinter ist"]
    G["<b>MCP-Gateway</b><br/>HTTP /tools, /tools/call<br/>Tool-Registry + Berechtigungs-Boundary"]

    T1["filesystem<br/><i>read_file, list_directory</i>"]
    T2["workspace<br/><i>workspace_read_file, workspace_list_directory</i>"]
    T3["github<br/><i>gh_read_file, gh_open_pr, ...</i>"]
    T4["imap<br/><i>imap_search, imap_fetch, ...</i>"]
    T5["web<br/><i>web_search, web_fetch</i>"]
    T6["memory<br/><i>memory_search, memory_save, ...</i>"]
    T7["host<br/><i>get_system_stats</i>"]
    T8["system<br/><i>self_update (HMAC-gated)</i>"]

    A -->|"HTTP POST /tools/call"| G
    G --> T1
    G --> T2
    G --> T3
    G --> T4
    G --> T5
    G --> T6
    G --> T7
    G --> T8

    classDef boundary fill:#fef3c7,stroke:#d97706,stroke-width:2px;
    class G boundary;

Was die Architektur leistet

  • Eine Schnittstelle, viele Werkzeuge. Der Agent ruft immer denselben Endpunkt: POST /tools/call mit {name, arguments}. Welches Tool hinter dem Namen steckt, ist Gateway-interne Sache.
  • Werkzeug-Hinzufügen ist eine Python-Datei. Ein neues Tool ist ein neues Modul in mcp-gateway/src/gateway/tools/, registriert auf der ToolRegistry. Der Agent muss dafür nichts lernen - er erfährt beim nächsten Boot über GET /tools, was es jetzt gibt.
  • Berechtigungen sitzen an der Gateway-Grenze. Tokens, Pfade, Mounts, Rate-Limits - alles wird im Gateway-Container konfiguriert. Der Agent kommt nie an einen rohen API-Key. Wenn ein halluzinierter Tool-Call etwas Verbotenes verlangt, weist das Gateway ihn ab - das ist eine zweite Schicht nach der Persona-Disziplin aus Kap 03.
  • Werkzeuge sind anbieterunabhängig. Wechsel von Claude zu GPT zu Kimi zu Llama: das MCP-Gateway bleibt. Jeder Anbieter hat sein eigenes natives Tool-Format (Anthropic: tool_use- Blöcke; OpenAI: tool_calls; etc.) - LiteLLM übersetzt das zur Laufzeit. Der Gateway sieht nur den fertig aufgelösten Aufruf.

Workspace-Tools: Lesezugriff auf Host-Projekte

Seit Juni 2026 kann Wintermute in die Projekt-Verzeichnisse auf dem Host schauen: workspace_read_file und workspace_list_directory lesen aus einem /workspaces-Mount, in dem die Git-Checkouts des Hosts liegen (inklusive Wintermutes eigenem Source-Tree).

Das ist genau die Art Feature, bei der die Berechtigungs-Frage über Leben und Tod entscheidet - ein Schreibzugriff auf den eigenen Source-Tree würde die Self-Modification-Sperre aus den GitHub-Tools (unten) komplett aushebeln. Deshalb ist der Zugriff doppelt read-only:

  • Es existiert kein Schreib-Tool. Die Tool-Surface hat nur read und list, nichts anderes ist registriert.
  • Das Volume selbst ist read-only gemountet (:ro im Compose-File). Selbst ein Bug oder eine Remote-Code-Execution im Gateway-Container könnte die Host-Repos nicht verändern.

Pfad-Traversal (../../../etc/passwd) wird über einen echten Pfad-Grenzen-Check abgewiesen, nicht über String-Vergleiche - das ist eine harte Grenze, keine Policy (Code in der Tech-Vertiefung). Und: die Tools sind reine Dateisystem-Reads, kein Git-Wrapper. Git-Historie und Refs brauchen weiterhin die GitHub-Tools.

Wintermutes konkretes Tool-Inventar (Stand 2026-07)

Modul Tools Was sie tun
filesystem read_file, list_directory Lesezugriff auf /documents (read-only Mount)
workspace workspace_read_file, workspace_list_directory Lesezugriff auf Host-Projekte unter /workspaces (doppelt read-only)
host get_system_stats CPU, RAM, Uptime, Disk
system self_update Triggert den Host-Updater via HMAC
memory memory_search, memory_save, memory_status, memory_forget, memory_load_doc Qdrant-Backend (siehe Kap 04)
web web_search, web_fetch DuckDuckGo / Brave + URL-Fetch + Markdown; web_fetch mit SSRF-Guard
github gh_read_file, gh_list_commits, gh_open_pr, gh_comment_issue, ... 15 Tools; Per-Repo-PAT-gemappt; eigenes Source-Repo: read-only
imap imap_list_accounts, imap_list_folders, imap_search, imap_fetch, imap_move, imap_mark E-Mail via konfigurierter Account-Datei

Trade-offs & offene Fragen

  • Tool-Latenz addiert sich. Jeder Werkzeug-Aufruf ist ein HTTP-Roundtrip. Bei lokalem Netz egal (~5ms), aber wer Werkzeuge über öffentliche APIs aufruft (GitHub, IMAP, Web) kommt schnell auf Sekunden pro Tool-Call. In einem Tool-Use-Loop mit 5-10 Iterationen summiert sich das.
  • Werkzeug-Beschreibungen sind Promptlast. Jedes registrierte Tool wird mit Name, Beschreibung und JSON-Schema in den System Prompt eingespielt. Bei 30+ Tools sind das schnell 10+ KB Prompt-Overhead - vor jedem einzelnen Turn. Faustregel: lieber wenige, gut benannte Tools als viele Spezial-Tools.
  • Halluzinierte Tool-Aufrufe. Modelle erfinden gelegentlich Tools, die es nicht gibt, oder geben falsche Argumente an. Das Gateway weist das sauber zurück (ToolNotFoundError, Schema-Validierung), und seit Juni 2026 prüft der Agent zusätzlich client-seitig vor jedem Dispatch, ob alle Required-Argumente aus dem Tool-Schema vorhanden sind (Code in der Tech-Vertiefung). Der Tool-Use-Loop selbst hat einen Duplicate-Call-Circuit-Breaker und ein Iterations-Cap. Aber die Persona muss verstehen, dass ein Fehlschlag ein Fakt ist, kein "neu versuchen mit geratenen Werten". Mehr dazu in Kap 07.
  • MCP ist jung. Der Standard ist von Ende 2024 - schnell iterierend, mit Tooling-Lücken. Wer heute MCP einsetzt, rechnet damit dass sich Details ändern. Wintermute hat bewusst eine eigene HTTP-Schicht statt der offiziellen Stdio-MCP-Referenz-Implementierung gewählt - siehe Tech-Vertiefung.
  • Was fehlt: ein vollständiger Tool-Use-Audit-Trail. Der Agent führt inzwischen ein leichtgewichtiges Pro-Turn-Log der Tool-Aufrufe (tool_call_log in agent/src/agent/core.py, Name + Argumente), das beim Iterations-Cap für Diagnose ausgegeben wird - aber eine strukturierte "Pro-Turn-Liste von Tool-Aufrufen mit Argumenten und Ergebnissen" für späteres Replay gibt es weiterhin nicht. Das wäre bei Halluzinations-Debugging Gold wert (siehe Kap 07).
  • Berechtigungs-Granularität ist grob. Aktuell: Tool-on-Off + per-Repo-Mapping für GitHub. Was es nicht gibt: "dieses Tool darf der User aufrufen, jenes nur der Admin", oder "nur in Arbeitszeiten". Für Single-User ausreichend, für Multi-User nicht.

🔧 Tech-Vertiefung

MCP vs. „eigene HTTP-Schicht" — Wintermutes Wahl

Der offizielle MCP-Standard nutzt stdio-Transport - Tool-Server sind Subprocesses, die über Stdin/Stdout sprechen. Wintermute hat sich bewusst dagegen entschieden und benutzt eine eigene HTTP-API (/tools, /tools/call).

Gründe:

  1. Eine Schicht, ein Container. Das Gateway ist bereits FastAPI; alle Tools laufen im selben Prozess als Python-Module. Ein stdio-MCP-Subprozess pro Tool würde die Container-Komplexität verdoppeln.
  2. HTTP ist debuggbar. curl http://gateway:8000/tools funktioniert; stdio ist deutlich aufwendiger zu inspizieren.
  3. Per-Repo-Token-Routing braucht eigenen Resolver. Auch wenn man den offiziellen GitHub-MCP-Server einsetzt, müsste die repo → token-Logik (LESSONS §19) irgendwo extern liegen - also kann sie auch gleich im eigenen Gateway sein.

Trade-off: man verliert die Werkzeug-Interoperabilität. Ein Wintermute-Tool ist kein Plug-and-Play-MCP-Server, den beliebige andere Clients nutzen könnten. Für ein persönliches Werkzeug ist das egal - für eine Plattform wäre es ein Hindernis.

Die ToolRegistry — die kompletten 62 Zeilen

Die ToolRegistry ist tatsächlich genau 62 Zeilen Python — und seit dem letzten Snapshot (2026-05-21) unverändert. Verbatim aus dem Repo:

# mcp-gateway/src/gateway/tools/registry.py
"""ToolRegistry — central tool list and dispatch."""

from __future__ import annotations

import inspect
from dataclasses import dataclass, field
from typing import Any, Callable


class ToolNotFoundError(Exception):
    pass


@dataclass
class ToolEntry:
    name: str
    description: str
    input_schema: dict
    handler: Callable


class ToolRegistry:
    """Register tools and dispatch calls by name."""

    def __init__(self) -> None:
        self._tools: dict[str, ToolEntry] = {}

    def register(
        self,
        name: str,
        description: str,
        input_schema: dict,
        handler: Callable,
    ) -> None:
        self._tools[name] = ToolEntry(
            name=name,
            description=description,
            input_schema=input_schema,
            handler=handler,
        )

    def list_tools(self) -> list[dict]:
        return [
            {
                "name": t.name,
                "description": t.description,
                "inputSchema": t.input_schema,
            }
            for t in self._tools.values()
        ]

    async def call(self, name: str, arguments: dict[str, Any]) -> Any:
        entry = self._tools.get(name)
        if entry is None:
            raise ToolNotFoundError(f"Tool not found: {name}")
        result = entry.handler(**arguments)
        if inspect.isawaitable(result):
            result = await result
        return result

    def __len__(self) -> int:
        return len(self._tools)

Drei Details, die in der vereinfachten Manager-Variante nicht direkt sichtbar sind:

  • from __future__ import annotations ist nicht Kosmetik: ohne dieses Future-Import würden Python-3.9-Compatibility- Probleme bei Typ-Hints auf Modul-Ebene auftreten. Wer auf Python 3.12+ baut, kann das weglassen — wer ältere Versionen unterstützen will, sollte es drin haben.
  • inspect.isawaitable(result) ist die saubere Erkennung ob ein Handler async ist. Damit kann die Registry sowohl synchrone als auch async Tools dispatchen, ohne dass der Caller den Unterschied wissen muss. Praktisch: einige Tools sind kurze CPU-Pfade (z.B. get_system_stats), andere brauchen await (HTTP-Calls, IMAP, GitHub-API).
  • __len__ ist klein, aber für Boot-Diagnostik wertvoll — len(registry) im Startup-Log zeigt sofort ob alle erwarteten Tools registriert sind.

Das ist die ganze Magie. Die /tools-Route gibt list_tools() raus, /tools/call ruft call(name, arguments).

Ein neues Tool anlegen

# mcp-gateway/src/gateway/tools/my_tool.py
from gateway.tools.registry import ToolRegistry

def register(registry: ToolRegistry) -> None:
    async def my_tool(path: str) -> str:
        # tatsächliche Arbeit
        return "ergebnis"

    registry.register(
        name="my_tool",
        description="Beschreibung was das Tool macht.",
        input_schema={
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
        handler=my_tool,
    )

Dann in mcp-gateway/src/gateway/main.py neben den anderen registrieren - fertig. Beim nächsten Gateway-Boot taucht das Tool in /tools auf und ist für den Agenten verfügbar.

Das Pattern in den realen Tool-Modulen ist immer dieses: pro Modul ein register(registry)-Einstiegspunkt, der die internen Handler-Funktionen als Closure registriert. Vorteil: das Modul kann seinen eigenen Lifecycle (z.B. eine HTTP-Client- Instanz für GitHub-Calls) als Closure-State halten, ohne globalen Zustand zu brauchen.

Workspace-Tools: der Pfad-Grenzen-Check

Die Workspace-Tools leben nicht in einem eigenen Modul, sondern in filesystem.py — sie sind eine zweite Registrierung derselben Handler-Factories, nur mit anderem Root:

# mcp-gateway/src/gateway/tools/filesystem.py (Auszug)
def register_workspace_tools(registry: ToolRegistry, workspaces_root: str) -> None:
    """Register read-only workspace filesystem tools on the given registry.

    Parallel to register_filesystem_tools but scoped to /workspaces.
    Only provides read_file and list_directory — no write operations.
    """
    registry.register(
        name="workspace_read_file",
        description="Read a file from the workspace mount at /workspaces. Returns file content as a string.",
        ...
        handler=_make_read_file(workspaces_root, prefix="/workspace:"),
    )

Interessanter ist der gemeinsame Pfad-Resolver, den read_file und workspace_read_file teilen. Verbatim:

# mcp-gateway/src/gateway/tools/filesystem.py (Auszug, Z.10-21)
def _safe_resolve(root: str, relative: str) -> str | None:
    """Resolve relative path within root. Returns None if traversal detected.

    Uses a real path-boundary check (``is_relative_to``), not a string-prefix
    test: a prefix test would let a sibling dir sharing the root's name escape
    (root ``/documents`` would wrongly accept ``/documents-private/...``).
    """
    root_p = Path(root).resolve()
    target = (root_p / relative).resolve()
    if target != root_p and not target.is_relative_to(root_p):
        return None
    return str(target)

Der Docstring benennt die klassische Falle selbst: ein String-Prefix-Test (startswith("/documents")) würde /documents-private/... durchlassen. Path.resolve() + is_relative_to ist der korrekte Check — Symlinks und .. werden vor dem Vergleich aufgelöst.

Die zweite Verteidigungslinie liegt außerhalb des Codes: das Volume ist in docker-compose.yml mit :ro gemountet (${WORKSPACES_DIR:-/home/dixie/projects}:/workspaces:ro). Selbst wenn jemand ein Schreib-Tool hinzufügen würde, ohne den Mount anzufassen, liefe der Write gegen ein read-only Filesystem. docs/WORKSPACE-TOOLS.md hält das explizit fest: sollte je ein Write-Tool kommen, muss es vor dem Lockern des Mounts gegen die Self-Modification-Guards abgesichert werden.

Client-seitige Argument-Validierung im Agenten

Neu seit Juni 2026, und auf der Agenten-Seite, nicht im Gateway: bevor call_tool einen Request abschickt, prüft es die Argumente gegen das gecachte Tool-Schema. Auslöser war ein realer Fall, in dem das Modell bei gh_put_file den kompletten (großen) content-Body wegließ. Verbatim:

# agent/src/agent/tools.py (Auszug)
def _missing_required_args(self, name: str, arguments: dict) -> list[str]:
    """Required schema fields absent from ``arguments`` (or explicitly None).

    Returns [] when the tool list isn't cached yet or the tool/schema is
    unknown — validation is best-effort and must never block a legitimate
    call just because discovery hasn't run. An empty string is treated as
    present (writing an empty file is valid); only a missing key or a None
    value counts as missing.
    """
    if not self._tools_cache:
        return []
    spec = next((t for t in self._tools_cache if t.get("name") == name), None)
    if spec is None:
        return []
    schema = spec.get("inputSchema") or spec.get("input_schema") or {}
    required = schema.get("required")
    if not isinstance(required, list):
        return []
    return [
        field for field in required
        if field not in arguments or arguments.get(field) is None
    ]

Schlägt der Check an, wird der Call nicht abgeschickt — stattdessen bekommt der Loop einen strukturierten Envelope (error_class: "IncompleteArguments"), dessen Fehlertext explizit sagt: "The tool was NOT called. Re-issue the call with the missing field(s) included." Zwei Design-Details:

  • Best-effort, nie blockierend. Kein Tool-Cache, unbekanntes Tool, kein required-Feld → Validierung greift nicht. Sie darf einen legitimen Call niemals wegen fehlender Discovery abweisen.
  • Leerstring zählt als vorhanden. Eine leere Datei schreiben ist valide; nur fehlender Key oder expliziter None-Wert gilt als fehlend.

Dazu kommen zwei Loop-Schutz-Mechanismen in agent/src/agent/core.py: ein Iterations-Cap (MAX_TOOL_ITERATIONS, Default 25) und ein Duplicate-Call-Circuit-Breaker — derselbe Tool-Name mit denselben (JSON-serialisierten, sortierten) Argumenten wird nach DUPLICATE_TOOL_CALL_LIMIT Ausführungen (Default 2) pro Turn kurzgeschlossen; das Modell bekommt statt des Ergebnisses eine synthetische Framework-Note. Beides gehört inhaltlich zu Kap 07 (Halluzinations-Defense), aber die Envelope-Konvention — Fehler als reguläre Tool-Results, nicht als Exceptions — ist die Voraussetzung dafür, dass es funktioniert.

Per-Repo-PAT-Mapping: die echte Implementierung (LESSONS §19)

Wintermutes GitHub-Tools sind das beste Beispiel für eine nicht-naive Berechtigungs-Lösung. Ein einziges PAT reicht nicht, sobald der Agent Repos in mehr als einem Account oder einer Org anfasst.

Lösung in GITHUB_TOKENS_JSON:

[
  {
    "repos": ["juergenvh/wintermute", "juergenvh/dotfiles"],
    "token": "github_pat_..."
  },
  {
    "repos": ["openai/whisper"],
    "token": "github_pat_..."
  }
]

Die Config-Klasse, die das parst, lebt in core/config.py. Verbatim die GitHubConfig (gekürzt um nicht-relevante Defaults):

# mcp-gateway/src/gateway/core/config.py (Auszug, Z.222-305)
@dataclass
class GitHubConfig:
    """Configuration for the GitHub tools.

    Parsed from two env vars:

    * ``WINTERMUTE_SELF_REPO`` — ``owner/name`` of *this*
      Wintermute's source repo. Empty string is legal and means
      "self-reflection disabled in this deployment". When set,
      it must also appear in one of the token entries (otherwise
      we'd be telling Wintermute he lives in a repo we have no
      key for).
    * ``GITHUB_TOKENS_JSON`` — a JSON array of
      ``{"repos": [...], "token": "..."}`` objects. Empty /
      missing means no tokens configured (tools register but
      short-circuit).
    """

    self_repo: str = ""
    tokens: tuple[GitHubTokenEntry, ...] = ()
    file_max_chars: int = 200_000
    body_max_chars: int = 20_000
    list_max_items: int = 100
    timeout_seconds: int = 15
    user_agent: str = "Wintermute/1.0 (+https://github.com/juergenvh/wintermute)"
    api_base: str = "https://api.github.com"

    @property
    def enabled(self) -> bool:
        """True when at least one token entry is configured."""
        return len(self.tokens) > 0

    @property
    def self_reflection_enabled(self) -> bool:
        """True when ``self_repo`` is set *and* covered by a token entry."""
        return bool(self.self_repo) and self.token_for(self.self_repo) is not None

    @property
    def configured_repos(self) -> tuple[str, ...]:
        out: list[str] = []
        for entry in self.tokens:
            out.extend(entry.repos)
        return tuple(out)

    def token_for(self, repo: str) -> str | None:
        """Return the configured token for ``repo`` or None if unmapped."""
        for entry in self.tokens:
            if repo in entry.repos:
                return entry.token
        return None

    @classmethod
    def from_env(cls) -> GitHubConfig:
        self_repo = _env_str("WINTERMUTE_SELF_REPO", "")
        if self_repo and not _REPO_RE.fullmatch(self_repo):
            raise GitHubConfigError(
                f"Invalid WINTERMUTE_SELF_REPO={self_repo!r}: must be 'owner/name'."
            )

        tokens = _parse_github_tokens(os.environ.get("GITHUB_TOKENS_JSON", ""))

        # Consistency check: if we claim a self-repo, we must have a token
        # that can actually reach it. Otherwise the agent ends up with an
        # identity it can't act on, which is worse than no identity at all.
        if self_repo and tokens:
            covered = any(self_repo in e.repos for e in tokens)
            if not covered:
                raise GitHubConfigError(...)

        return cls(self_repo=self_repo, tokens=tokens, ...)

Drei Designentscheidungen, die hier zusammenkommen:

  • token_for(repo) ist eine lineare Suche. Bei N Token-Entries und R Repos pro Entry ist das O(N×R). Wer 300 Repos verwaltet, würde ein Dict bauen — Wintermute lebt mit zweistelligen Repo-Zahlen, lineare Suche ist genug schnell und einfacher zu auditieren.
  • Boot-Time-Konsistenz-Check. Wenn WINTERMUTE_SELF_REPO gesetzt ist und Token-Entries existieren, muss das Self-Repo abgedeckt sein. Das Symptom „Agent denkt er lebt in einem Repo, hat aber keinen Token dafür" wäre stiller Fehlbetrieb — Fail-Fast bei Boot ist die saubere Antwort.
  • from_env() als Classmethod-Konstruktor. Statt die ENV- Logik in __init__ zu packen, ist sie ein zweiter Pfad. Das macht Tests trivial: GitHubConfig(self_repo="foo/bar", tokens=(...)) baut die Klasse ohne ENV-Variable.

Bei Tool-Call die eigentliche Resolver-Funktion in tools/github.py:

# mcp-gateway/src/gateway/tools/github.py (Auszug, Z.112-133)
def _resolve_repo(
    cfg: GitHubConfig, repo: str
) -> tuple[str | None, dict[str, Any] | None]:
    """Resolve ``repo`` to a token; return ``(token, error_envelope)``.

    Exactly one of the two will be non-None.
    """
    if not repo or not isinstance(repo, str):
        return None, _err("repo must be a non-empty 'owner/name' string", "bad_repo")
    repo = repo.strip()
    if "/" not in repo:
        return None, _err(
            f"repo must be in 'owner/name' form, got {repo!r}", "bad_repo"
        )
    token = cfg.token_for(repo)
    if token is None:
        return None, _err(
            f"no token configured for {repo!r} (check GITHUB_TOKENS_JSON)",
            "no_token",
        )
    return token, None

Strukturierte Error-Envelopes (bad_repo, no_token) statt Exceptions. Der Agent kann diese als reguläre Tool-Antwort sehen und sinnvoll reagieren — nicht eine Stacktrace-artige Failure, die das Modell mit "ich probier's nochmal mit anderen Argumenten" beantworten würde.

Self-Modification-Sperre auf Tool-Ebene

Zusätzlich gibt es eine zweite Sicherheits-Schicht für das eigene Source-Repo: gh_put_file, gh_create_branch, gh_open_pr gegen WINTERMUTE_SELF_REPO werden auf Tool-Ebene abgewiesen. Lesen ist erlaubt, schreiben nicht.

Verbatim aus github.py:

# mcp-gateway/src/gateway/tools/github.py (Auszug, Z.190-219)
async def _self_repo_guard(
    cfg: GitHubConfig, gh: _GitHub, repo: str, op: str
) -> dict[str, Any] | None:
    """Refuse all code-modifying writes targeting ``self_repo``.

    Self-modification was retired on 2026-05-06 (see
    ``docs/HALLUCINATIONS.md``). The persona-prompt tells Wintermute that
    code-changes to himself are produced by the sister-agent Dixie; this
    guard is the tool-level enforcement of that promise. Issues and
    comments stay reachable — they are the escalation surface.

    ``op`` is a short tag (``"put_file"``, ``"create_branch"``,
    ``"open_pr"``) included in the error envelope so the caller can tell
    which surface tripped.

    Returns an error envelope to be returned as-is, or None if the write
    is unrelated to self-repo and should proceed.
    """
    if not cfg.self_repo or repo != cfg.self_repo:
        return None
    return _err(
        f"refused: {repo!r} is the configured self-repo. Self-modification "
        f"is disabled (gh_{op} write path). Wintermute does not produce "
        f"code changes against his own source any more — implementation "
        f"work is done by the sister-agent Dixie. If you want a change, "
        f"open an issue (gh_create_issue) describing what and why.",
        "self_repo_guard",
    )

Bemerkenswert am Design:

  • Der Guard ist Opt-Out, nicht Opt-In. Wenn cfg.self_repo leer ist, gibt es nichts zu blocken — das Tool funktioniert normal. Erst das Setzen der Variable aktiviert die Sperre. Damit zahlt eine Wintermute-Fork- Instanz die Komplexität nur, wenn sie auch wirklich Selbst-Wahrnehmung will.
  • Die Fehlermeldung ist ein vollständiger Eskalations-Pfad. Sie sagt nicht nur „verboten", sondern erklärt warum (Link auf HALLUCINATIONS.md) und nennt die Alternative (gh_create_issue). Das ist wichtig, weil das LLM diese Antwort als Tool-Result sieht — die Erklärung wird Teil seines Kontexts und beeinflusst die nächste Aktion.
  • Aufruf-Stellen sind explizit. Der Guard wird in genau drei Stellen aufgerufen: gh_create_branch, gh_put_file, gh_open_pr. Eine versteckte Opt-In-Mechanik („alle schreibenden Tools werden automatisch gegen Self-Repo gecheckt") wäre subtiler und damit fehleranfälliger; die drei expliziten Aufrufe sind im Code grep-bar.

Warum zwei Schichten (Tool-Guard + GitHub Branch Protection)? Defense in depth. Wenn ein Layer versagt (Bug im Tool-Guard, falscher Token-Mapping, GitHub-API-Verhalten ändert sich), fängt der andere noch. Die Workspace-Tools (oben) sind die dritte Stelle, an der dieselbe Grenze verteidigt wird: das /workspaces-Mount enthält den Self-Repo-Checkout, deshalb gibt es dort strukturell keinen Schreibpfad.

SSRF-Guard in web_fetch

web_fetch holt URLs, die das LLM bestimmt — und das LLM liest untrusted Input (Telegram, Web-Seiten). Ohne Guard könnte eine Prompt-Injection den Agenten interne Adressen abfragen lassen: den Cloud-Metadata-Endpunkt 169.254.169.254, den Qdrant-Store, den Host-Updater. Seit dem Security-Hardening im Juni 2026 steht davor ein SSRF-Guard. Der Kern verbatim:

# mcp-gateway/src/gateway/tools/web.py (Auszug, Z.57-73)
def _ip_is_blocked(ip: ipaddress._BaseAddress) -> bool:
    """True if ``ip`` is in a range web_fetch must never reach.

    Blocks loopback, link-local (incl. the cloud-metadata 169.254.169.254),
    private, reserved, multicast, and unspecified addresses. IPv4-mapped IPv6
    is unwrapped before the check.
    """
    if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
        ip = ip.ipv4_mapped
    return (
        ip.is_loopback
        or ip.is_link_local
        or ip.is_private
        or ip.is_reserved
        or ip.is_multicast
        or ip.is_unspecified
    )

Drei Details, die den Unterschied zwischen "Blockliste" und "Guard" ausmachen:

  • Der Hostname wird aufgelöst, nicht gepattern-matched. _assert_host_allowed ruft getaddrinfo und prüft jede A/AAAA-Antwort — eine einzige interne Adresse in der DNS-Antwort lehnt den Request ab. Das entschärft DNS-Rebinding-Tricks für den jeweiligen Hop.
  • IPv4-mapped IPv6 wird entpackt. ::ffff:127.0.0.1 ist sonst ein klassischer Bypass für IPv4-only-Blocklisten.
  • Redirects werden manuell verfolgt. _do_fetch setzt follow_redirects=False und prüft vor jedem Hop den neuen Host — httpx mit follow_redirects=True würde einem 302 auf eine interne Adresse folgen, ohne dass der Guard je gefragt wird. Cap: WEB_FETCH_MAX_REDIRECTS (Default 5).

Override für bewusst-lokale Setups: WEB_FETCH_ALLOW_PRIVATE_HOSTS=true — Default ist aus.

Security-Posture im Tool-Ökosystem

docs/SECURITY.md formuliert es so: "Containers can't touch the host." Der MCP-Gateway-Container ist:

  • Unprivileged (uid 1000)
  • Kein Docker-Socket-Zugriff (war anders, siehe LESSONS §5/§9 - wurde 2026-05 entfernt)
  • Kein SSH-Key-Mount
  • Documents-Mount read-only
  • Workspaces-Mount read-only (:ro, zusätzlich zur read-only Tool-Surface)
  • Secrets via Group-readable Files, nicht via ENV

Diese Posture ist im Compose-File selbst sichtbar (siehe Kap 02 Dev-Fassung, mcp-gateway-Service-Block) — der Block- Kommentar dort listet auf, was früher gemountet war und bewusst entfernt wurde. Das ist nicht Paranoia - es ist Risikomanagement gegen den realistischen Bedrohungs-Vektor: Prompt-Injection durch LLM-gefütterten Tool-Output. Wenn web_fetch eine manipulierte Seite holt, die den Agenten zu einem schädlichen Tool-Call überredet, soll das Gateway strukturell nicht in der Lage sein, etwas Schlimmes zu tun. docs/SECURITY.md führt inzwischen auch eine Checkliste für neue Tools — u.a.: "Does it fetch a URL the LLM can influence? Apply the SSRF guard."

Empfehlungen für eigene Tool-Implementierungen

  1. Ein Gateway, viele Tools. Nicht pro Tool ein eigener Container. Komplexität wird nicht günstiger durch Verteilung.
  2. HTTP-Schicht, nicht stdio. Es sei denn, du willst bewusst MCP-Server-Kompatibilität - dann offizielle Reference-Implementierung.
  3. Token-Mapping von Anfang an. Auch wenn du heute nur ein GitHub-Token hast, modelliere es als Liste. Migration später ist teurer als Vorbau jetzt.
  4. Tool-Errors als strukturierte Envelopes, nicht als Exceptions. Der Agent muss wissen was schiefging (Kap 07: Halluzinations-Defense baut darauf auf).
  5. Mounts read-only, Container unprivileged. Die Bequemlichkeits-Variante ist ein Sicherheitsloch.
  6. Tool-Beschreibungen kurz halten. Sie landen in jedem System Prompt. 200 Zeichen pro Tool, nicht 2000.
  7. Defense in depth bei Schreib-Tools. Tool-Guard und externe Berechtigungs-Grenze (Branch Protection, Read-only-Mount, ...). Eine Schicht reicht nicht.
  8. Required-Argumente client-seitig prüfen. Das Modell lässt gelegentlich Pflichtfelder weg (gerade große Bodies). Ein Best-effort-Check gegen das Tool-Schema vor dem Dispatch gibt dem Loop ein klares Signal statt eines still fehlgeleiteten Calls.
  9. SSRF-Guard, sobald das LLM URLs bestimmt. Host auflösen, interne Ranges blocken (inkl. IPv4-mapped IPv6), Redirects manuell verfolgen und pro Hop neu prüfen.

📚 Quellen im Wintermute-Repo