KIMCPPythonPlaywrightTestautomatisierung

MCP-Server mit Python III: Einbindung von Playwright

Browser-Automatisierung per Chat mit Test-Recording

Im zweiten Teil hatte ich es angekündigt: Browser-Integration. Jetzt wird es konkret. Dieser Beitrag zeigt einen MCP-Server, der Playwright steuert – inklusive integrierter Test-Aufzeichnung, die aus interaktiven Browser-Sessions lauffähigen pytest-Code generiert.

Update Januar 2026: Die Test-Generierung ist jetzt implementiert. Der Code ist auf GitHub verfügbar.

Was ich erreichen will

Bevor ich Code schreibe, kurz die Vision:

  1. Tests interaktiv entwerfen – Ich beschreibe einen Flow (“Logge ein, geh zum Warenkorb, prüfe den Preis”), Claude führt aus und generiert daraus sauberen pytest-Code.

  2. Code nach meinen Regeln – Der generierte Test soll so aussehen, wie ich ihn selbst schreiben würde. Page Objects, klare Struktur, lesbar für’s Team.

  3. Crawling-Helfer – Manchmal will ich eine Seite systematisch durchgehen. Links sammeln, Struktur verstehen, Screenshots machen.

  4. Ich bleibe Herr des Verfahrens – Das ist mir wichtig. Die KI ist Werkzeug, nicht Autopilot. Ich sage was passiert, ich prüfe das Ergebnis, ich entscheide was in den Test kommt.

Die Herausforderung: Stateful Browser

Anders als die bisherigen Tools (Datei lesen, Befehl ausführen) ist ein Browser stateful. Er hat eine Session, offene Tabs, Cookies. Ich kann nicht für jeden Tool-Aufruf einen neuen Browser starten – das wäre viel zu langsam und würde den Zustand verlieren.

Die Lösung: Der Browser startet einmal beim Server-Start und bleibt offen. Die Tools arbeiten alle mit derselben Browser-Instanz.

Installation

Neben den MCP-Abhängigkeiten brauchen wir Playwright:

pip install playwright
playwright install chromium

Der Server

Hier das vollständige Gerüst. Ich erkläre die einzelnen Teile danach.

#!/usr/bin/env python3
"""MCP-Server mit Playwright-Integration."""

import asyncio
import base64
import sys
from pathlib import Path

from mcp.server import Server
from mcp.server.stdio import stdio_server
from playwright.async_api import async_playwright, Browser, Page

mcp = Server("playwright-tools")

# Globaler Zustand
_browser: Browser | None = None
_page: Page | None = None
_playwright = None


async def get_page() -> Page:
    """Liefert die aktuelle Page, startet Browser falls nötig."""
    global _browser, _page, _playwright

    if _page is None or _page.is_closed():
        if _playwright is None:
            _playwright = await async_playwright().start()
        if _browser is None:
            _browser = await _playwright.chromium.launch(
                headless=True,  # False für Debugging
            )
        _page = await _browser.new_page()

    return _page


async def cleanup():
    """Räumt Browser-Ressourcen auf."""
    global _browser, _page, _playwright

    if _page and not _page.is_closed():
        await _page.close()
    if _browser:
        await _browser.close()
    if _playwright:
        await _playwright.stop()

    _page = None
    _browser = None
    _playwright = None


# =============================================================================
# Navigation
# =============================================================================

@mcp.tool()
async def navigate(url: str) -> str:
    """Navigiert zu einer URL. Wartet bis die Seite geladen ist."""
    page = await get_page()
    try:
        await page.goto(url, wait_until="domcontentloaded")
        return f"Navigiert zu: {page.url}"
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def current_url() -> str:
    """Gibt die aktuelle URL zurück."""
    page = await get_page()
    return page.url


@mcp.tool()
async def go_back() -> str:
    """Geht eine Seite zurück."""
    page = await get_page()
    await page.go_back()
    return f"Zurück zu: {page.url}"


# =============================================================================
# Seiten-Inhalt
# =============================================================================

@mcp.tool()
async def get_title() -> str:
    """Gibt den Seitentitel zurück."""
    page = await get_page()
    return await page.title()


@mcp.tool()
async def get_text(selector: str) -> str:
    """Liest den Text eines Elements aus."""
    page = await get_page()
    try:
        element = page.locator(selector)
        await element.wait_for(timeout=5000)
        return await element.inner_text()
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def get_all_texts(selector: str) -> str:
    """Liest den Text aller passenden Elemente aus."""
    page = await get_page()
    try:
        elements = page.locator(selector)
        count = await elements.count()
        if count == 0:
            return "(keine Elemente gefunden)"

        texts = []
        for i in range(count):
            text = await elements.nth(i).inner_text()
            texts.append(f"{i+1}. {text.strip()}")

        return "\n".join(texts)
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def get_page_content(max_length: int = 10000) -> str:
    """Gibt den sichtbaren Text der Seite zurück (für Kontext)."""
    page = await get_page()
    try:
        # Nur sichtbarer Text, kein HTML
        text = await page.inner_text("body")
        text = "\n".join(line.strip() for line in text.split("\n") if line.strip())

        if len(text) > max_length:
            text = text[:max_length] + "\n... (gekürzt)"

        return text
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def get_links() -> str:
    """Listet alle Links auf der Seite auf."""
    page = await get_page()
    try:
        links = await page.eval_on_selector_all(
            "a[href]",
            """elements => elements.map(e => ({
                text: e.innerText.trim().substring(0, 50),
                href: e.href
            })).filter(l => l.text && l.href)"""
        )

        if not links:
            return "(keine Links gefunden)"

        return "\n".join(f"- {l['text']}: {l['href']}" for l in links[:50])
    except Exception as e:
        return f"Fehler: {e}"


# =============================================================================
# Interaktion
# =============================================================================

@mcp.tool()
async def click(selector: str) -> str:
    """Klickt auf ein Element."""
    page = await get_page()
    try:
        await page.click(selector, timeout=5000)
        await page.wait_for_load_state("domcontentloaded")
        return f"Geklickt: {selector}"
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def fill(selector: str, text: str) -> str:
    """Füllt ein Eingabefeld aus."""
    page = await get_page()
    try:
        await page.fill(selector, text, timeout=5000)
        return f"Ausgefüllt: {selector}"
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def press(key: str) -> str:
    """Drückt eine Taste (z.B. 'Enter', 'Tab', 'Escape')."""
    page = await get_page()
    try:
        await page.keyboard.press(key)
        return f"Taste gedrückt: {key}"
    except Exception as e:
        return f"Fehler: {e}"


@mcp.tool()
async def select_option(selector: str, value: str) -> str:
    """Wählt eine Option in einem Dropdown aus."""
    page = await get_page()
    try:
        await page.select_option(selector, value, timeout=5000)
        return f"Ausgewählt: {value} in {selector}"
    except Exception as e:
        return f"Fehler: {e}"


# =============================================================================
# Screenshots
# =============================================================================

@mcp.tool()
async def screenshot(path: str = "screenshot.png", full_page: bool = False) -> str:
    """Macht einen Screenshot der aktuellen Seite."""
    page = await get_page()
    try:
        filepath = Path(path).expanduser()
        await page.screenshot(path=str(filepath), full_page=full_page)
        return f"Screenshot gespeichert: {filepath}"
    except Exception as e:
        return f"Fehler: {e}"


# =============================================================================
# Warten
# =============================================================================

@mcp.tool()
async def wait_for(selector: str, timeout: int = 10000) -> str:
    """Wartet bis ein Element sichtbar ist."""
    page = await get_page()
    try:
        await page.wait_for_selector(selector, timeout=timeout)
        return f"Element gefunden: {selector}"
    except Exception as e:
        return f"Fehler (Timeout?): {e}"


@mcp.tool()
async def wait_seconds(seconds: float) -> str:
    """Wartet eine bestimmte Zeit (für Debugging)."""
    await asyncio.sleep(seconds)
    return f"Gewartet: {seconds}s"


# =============================================================================
# Browser-Steuerung
# =============================================================================

@mcp.tool()
async def new_page() -> str:
    """Öffnet einen neuen Tab."""
    global _page
    if _browser is None:
        await get_page()  # Browser initialisieren

    _page = await _browser.new_page()
    return "Neuer Tab geöffnet"


@mcp.tool()
async def close_page() -> str:
    """Schließt den aktuellen Tab."""
    global _page
    if _page and not _page.is_closed():
        await _page.close()
        _page = None
        return "Tab geschlossen"
    return "Kein Tab offen"


@mcp.tool()
async def set_headless(headless: bool) -> str:
    """Startet Browser neu im headless/headed Modus."""
    await cleanup()
    global _browser, _playwright

    _playwright = await async_playwright().start()
    _browser = await _playwright.chromium.launch(headless=headless)

    mode = "headless" if headless else "headed"
    return f"Browser neu gestartet im {mode} Modus"


# =============================================================================
# Main
# =============================================================================

async def main():
    try:
        async with stdio_server() as (read, write):
            await mcp.run(read, write)
    finally:
        await cleanup()


if __name__ == "__main__":
    asyncio.run(main())

Die Tools im Überblick

ToolKategorieBeschreibung
navigate(url)NavigationÖffnet eine URL
current_url()NavigationZeigt aktuelle URL
go_back()NavigationBrowser-Zurück
get_title()InhaltSeitentitel
get_text(selector)InhaltText eines Elements
get_all_texts(selector)InhaltText aller passenden Elemente
get_page_content()InhaltSichtbarer Text der ganzen Seite
get_links()InhaltAlle Links auflisten
click(selector)InteraktionKlickt ein Element
fill(selector, text)InteraktionFüllt Eingabefeld
press(key)InteraktionTaste drücken
select_option(selector, value)InteraktionDropdown auswählen
screenshot(path)HilfsScreenshot speichern
wait_for(selector)HilfsAuf Element warten
wait_seconds(n)HilfsPause (Debugging)
new_page()HilfsNeuer Tab
set_headless(bool)HilfsBrowser-Modus wechseln

Hinweis: Das get_page_content() ist besonders wichtig: Damit kann Claude die Seite “sehen” und versteht den Kontext. Ohne das rät die KI bei Selektoren nur.

Registrierung in Claude Desktop

Die config.json wie gehabt:

{
  "mcpServers": {
    "playwright": {
      "command": "python",
      "args": ["/pfad/zu/playwright_server.py"]
    }
  }
}

Erste Schritte

Nach dem Neustart von Claude Desktop kann ich direkt loslegen:

“Öffne uc-it.de und zeig mir die Navigation”

Claude ruft navigate("https://www.uc-it.de") auf, dann get_page_content() um die Seite zu verstehen, und kann mir die Struktur erklären.

“Mach einen Screenshot”

screenshot("ucit.png") – fertig.

“Welche Links gibt es im Hauptmenü?”

Claude muss erstmal den richtigen Selektor finden. Mit get_page_content() bekommt es Kontext, dann get_all_texts("nav a") oder ähnlich.

Headed-Modus fürs Debugging

Standardmäßig läuft der Browser headless – unsichtbar im Hintergrund. Für Debugging ist es hilfreich, ihn zu sehen:

“Starte den Browser im headed-Modus”

set_headless(False) – jetzt öffnet sich ein Chrome-Fenster und ich kann zusehen was passiert.

Was jetzt funktioniert

Test-Recording ✓

Der Workflow funktioniert jetzt wie geplant:

  1. start_recording("mein_test") – Aufzeichnung starten
  2. Browser-Aktionen ausführen (navigate, click, get_title, …)
  3. stop_recording() – Aufzeichnung beenden, Preview anzeigen
  4. save_test() – pytest-Code in generated/ speichern

Jeder Tool-Aufruf wird protokolliert und in validen pytest-Code übersetzt. Das Ergebnis:

def test_mein_test(page: Page) -> None:
    page.goto(f"{BASE_URL}/about/")
    expect(page).to_have_title(re.compile(re.escape("Über mich")), timeout=5000)
    page.click("nav a[href='/kontakt/']")
    # ... weitere Schritte

Was noch offen ist

Page Objects und Templates

Der generierte Code ist funktional, aber noch “flach”. Für größere Projekte wäre sinnvoll:

  • Page Object Pattern für wiederverwendbare Selektoren
  • Konfigurierbare Templates für Team-Konventionen
  • Fixtures für Setup/Teardown

Crawling-Automatik

Manchmal will ich eine Seite systematisch durchgehen:

  • Alle Unterseiten finden
  • Struktur dokumentieren
  • Screenshots von jeder Seite
  • Broken Links finden

Das geht mit den vorhandenen Tools schon manuell, aber eine automatisierte Variante wäre praktischer.

Selbstheilende Selektoren (Idee)

Wenn sich eine Seite ändert und ein Selektor nicht mehr funktioniert – kann Claude einen alternativen finden? Die Seite analysieren, ähnliche Elemente suchen, Vorschlag machen?

Das ist noch Zukunftsmusik, aber das Gerüst macht es möglich.

Warum nicht einfach Codegen?

Playwright hat einen eingebauten Codegen (playwright codegen), der Aktionen aufzeichnet. Warum also dieser Umweg?

  • Codegen generiert, was man tut – Ich will aber beschreiben, was passieren soll
  • Codegen macht rohen Code – Ich will strukturierten Code nach meinen Regeln
  • Codegen ist statisch – Die MCP-Variante kann auf Probleme reagieren

Außerdem: Es macht Spaß. Ich lerne dabei wie MCP funktioniert und wie weit man LLMs bei sowas treiben kann.

Kontrolle behalten

Ein Punkt, der mir wichtig ist: Ich bleibe Herr des Verfahrens.

Die KI führt aus, was ich sage. Sie generiert Code, den ich prüfe. Sie macht Vorschläge, die ich annehme oder ablehne. Es ist ein Werkzeug, kein Autopilot.

Gerade bei Tests ist das entscheidend. Ein Test, den ich nicht verstehe, ist wertlos. Ein Test, der “magisch” funktioniert aber keiner weiß warum, wird zum Problem wenn er fehlschlägt.

Also: Claude hilft beim Erstellen, aber der Test gehört mir. Ich muss ihn verstehen, erklären können, warten können.

Nächste Schritte

Was als nächstes kommen könnte:

  1. Protokollierung – Alle Tool-Aufrufe mitschreiben
  2. Export-Funktion – Aus Protokoll wird Test-Code
  3. Code-Templates – Page Object Pattern, Team-Konventionen
  4. Feinschliff – Selektoren optimieren, Waits einbauen
  5. Crawling-Modus – Automatisches Durchlaufen aller Seiten

Disclaimer: Der Code ist ein Proof of Concept aus meiner täglichen Arbeit. Funktioniert für meine Zwecke, keine Garantie für andere Setups. Die Pfade und Strukturen müssen natürlich angepasst werden.

GitHub: cuber-it/mcp_playwright_tools

Aktualisiert: 2026-01-05