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:
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.
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.
Crawling-Helfer – Manchmal will ich eine Seite systematisch durchgehen. Links sammeln, Struktur verstehen, Screenshots machen.
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
| Tool | Kategorie | Beschreibung |
|---|---|---|
navigate(url) | Navigation | Öffnet eine URL |
current_url() | Navigation | Zeigt aktuelle URL |
go_back() | Navigation | Browser-Zurück |
get_title() | Inhalt | Seitentitel |
get_text(selector) | Inhalt | Text eines Elements |
get_all_texts(selector) | Inhalt | Text aller passenden Elemente |
get_page_content() | Inhalt | Sichtbarer Text der ganzen Seite |
get_links() | Inhalt | Alle Links auflisten |
click(selector) | Interaktion | Klickt ein Element |
fill(selector, text) | Interaktion | Füllt Eingabefeld |
press(key) | Interaktion | Taste drücken |
select_option(selector, value) | Interaktion | Dropdown auswählen |
screenshot(path) | Hilfs | Screenshot speichern |
wait_for(selector) | Hilfs | Auf Element warten |
wait_seconds(n) | Hilfs | Pause (Debugging) |
new_page() | Hilfs | Neuer Tab |
set_headless(bool) | Hilfs | Browser-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:
start_recording("mein_test")– Aufzeichnung starten- Browser-Aktionen ausführen (navigate, click, get_title, …)
stop_recording()– Aufzeichnung beenden, Preview anzeigensave_test()– pytest-Code ingenerated/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:
Protokollierung – Alle Tool-Aufrufe mitschreiben✓Export-Funktion – Aus Protokoll wird Test-Code✓- Code-Templates – Page Object Pattern, Team-Konventionen
- Feinschliff – Selektoren optimieren, Waits einbauen
- 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