From c9ccb4392b4f049e146adb832f13f8c61e35395a Mon Sep 17 00:00:00 2001 From: Stefan Lohmaier Date: Wed, 24 Jun 2026 21:17:26 +0200 Subject: [PATCH] harden: per-item robustness + deadlines/caps across connectors; layered tests Mail: _safe_decode + _iter_messages (per-mail try/except) -> eine kaputte Mail (charset x-unknown) bricht die Suche nicht mehr ab, findet so auch uralte Mails. Vollscan + Datum-Sort + MAX_SCAN/DEADLINE_S (Selbstheilung, kein 24h-Haenger mehr). Files: search_files mit SEARCH_DEADLINE_S + SEARCH_MAX_DIRS begrenzt (war unbegrenzte rekursive PROPFIND -> real 35s/449 Calls). Calendar/Contacts: PARSE_DEADLINE_S fuer client-seitiges vobject-Parsen; MAX_RESULTS-Output-Cap in get_events/get_tasks (war 10939 Zeilen bei 6000 Events); search Top-N + Hinweise. Tests: in Schichten getrennt -> Smoke (nightly), Edge + Stress (on-demand). Neu: test_smoke/test_edge/test_stress + conftest-Marker + run_full_tests.sh, nightly laeuft nur Smoke. Kontakt-Foto-Roundtrip-Test, Mail-Edge-Mails (x-unknown, kaputter Header, 2009er Alt-Mail). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + calendar/server.py | 38 +++- contacts/server.py | 19 +- files/server.py | 23 ++- mail/server.py | 82 ++++++-- tests/MCPTEST.md | 60 ++++++ tests/conftest.py | 12 ++ tests/run_full_tests.sh | 28 +++ tests/run_tests.sh | 14 +- tests/test_all.py | 46 +++++ tests/test_edge.py | 183 ++++++++++++++++++ tests/test_smoke.py | 53 +++++ tests/test_stress.py | 114 +++++++++++ tests/testdata/gen_maildir.py | 27 +++ ...S => 1782276881.M347842P929578Q3.polo:2,S} | 0 .../cur/1782276881.M377400P929578Q10.polo:2,S | 9 + ...S => 1782276881.M344056P929578Q1.polo:2,S} | 0 ...S => 1782276881.M350723P929578Q4.polo:2,S} | 10 +- ...S => 1782276881.M355288P929578Q5.polo:2,S} | 10 +- ...S => 1782276881.M362041P929578Q6.polo:2,S} | 10 +- ...S => 1782276881.M366317P929578Q7.polo:2,S} | 8 +- ...S => 1782276881.M369074P929578Q8.polo:2,S} | 12 +- ...S => 1782276881.M374012P929578Q9.polo:2,S} | 10 +- .../cur/1782276881.M378820P929578Q11.polo:2,S | 8 + .../cur/1782276881.M379851P929578Q12.polo:2,S | 7 + ...S => 1782276881.M346155P929578Q2.polo:2,S} | 0 26 files changed, 719 insertions(+), 65 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/run_full_tests.sh create mode 100644 tests/test_edge.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_stress.py rename tests/testdata/maildir/mcp-test-empty/INBOX/cur/{1781848411.M886272P2002983Q3.polo:2,S => 1782276881.M347842P929578Q3.polo:2,S} (100%) create mode 100644 tests/testdata/maildir/mcp-test-mail/Archive/cur/1782276881.M377400P929578Q10.polo:2,S rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M882783P2002983Q1.polo:2,S => 1782276881.M344056P929578Q1.polo:2,S} (100%) rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M887958P2002983Q4.polo:2,S => 1782276881.M350723P929578Q4.polo:2,S} (98%) rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M889989P2002983Q5.polo:2,S => 1782276881.M355288P929578Q5.polo:2,S} (98%) rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M892584P2002983Q6.polo:2,S => 1782276881.M362041P929578Q6.polo:2,S} (99%) rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M895485P2002983Q7.polo:2,S => 1782276881.M366317P929578Q7.polo:2,S} (98%) rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M897192P2002983Q8.polo:2,S => 1782276881.M369074P929578Q8.polo:2,S} (80%) rename tests/testdata/maildir/mcp-test-mail/INBOX/cur/{1781848411.M899539P2002983Q9.polo:2,S => 1782276881.M374012P929578Q9.polo:2,S} (99%) create mode 100644 tests/testdata/maildir/mcp-test-mail/INBOX/cur/1782276881.M378820P929578Q11.polo:2,S create mode 100644 tests/testdata/maildir/mcp-test-mail/INBOX/cur/1782276881.M379851P929578Q12.polo:2,S rename tests/testdata/maildir/mcp-test-mail/Sent/cur/{1781848411.M884723P2002983Q2.polo:2,S => 1782276881.M346155P929578Q2.polo:2,S} (100%) diff --git a/.gitignore b/.gitignore index 1b8becf..3422d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ config.json *.before-* .token_audit.log *.bak-* +*.bak.* diff --git a/calendar/server.py b/calendar/server.py index b876275..d1c1792 100644 --- a/calendar/server.py +++ b/calendar/server.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from typing import Annotated import httpx, vobject +import time from pydantic import Field from mcp.server.fastmcp import FastMCP from starlette.applications import Starlette @@ -58,9 +59,15 @@ def _report_tasks(href, auth, inc=False): body = f'{filt}' return httpx.request("REPORT", RADICALE + href, content=body, auth=auth, headers={"Depth": "1", "Content-Type": "application/xml"}, timeout=30).text -def _parse(xml, comp="VEVENT"): +PARSE_DEADLINE_S = 12 # Wall-Clock-Limit fuer client-seitiges vobject-Parsen +MAX_RESULTS = 300 # Output-Cap (sonst Token-Explosion bei tausenden Items) + + +def _parse(xml, comp="VEVENT", t0=None, cap=None): objs = [] for m in re.finditer(r'<(?:c|C):calendar-data[^>]*>(.*?)', xml, re.DOTALL): + if cap is not None and len(objs) >= cap: break + if t0 is not None and (time.monotonic() - t0) > PARSE_DEADLINE_S: break raw = m.group(1).replace("<","<").replace(">",">").replace("&","&") try: for c in vobject.readOne(raw).components(): @@ -133,11 +140,16 @@ def get_events( if not cals: return f"Kalender '{calendar}' nicht gefunden. Nutze list_calendars." s = datetime.strptime(date_from, "%Y-%m-%d").strftime("%Y%m%dT000000Z") e = datetime.strptime(date_to, "%Y-%m-%d").strftime("%Y%m%dT235959Z") + t0 = time.monotonic() results = [] for cal in cals: - for ev in _parse(_report(cal["href"], _auth(user), s, e)): + for ev in _parse(_report(cal["href"], _auth(user), s, e), "VEVENT", t0=t0, cap=MAX_RESULTS - len(results)): results.append(_fmt_ev(ev, cal["name"])) - return "\n\n".join(results) if results else "Keine Termine in diesem Zeitraum" + if len(results) >= MAX_RESULTS or (time.monotonic() - t0) > PARSE_DEADLINE_S: break + out = "\n\n".join(results) if results else "Keine Termine in diesem Zeitraum" + if len(results) >= MAX_RESULTS: + out += f"\n\n[Nur {MAX_RESULTS} Termine gezeigt — Zeitraum eingrenzen]" + return out @mcp.tool() @@ -154,13 +166,20 @@ def search_events( s = datetime.strptime(date_from, "%Y-%m-%d").strftime("%Y%m%dT000000Z") e = datetime.strptime(date_to, "%Y-%m-%d").strftime("%Y%m%dT235959Z") q = query.lower() + t0 = time.monotonic() results = [] + truncated = False for cal in _discover(user, "VEVENT"): - for ev in _parse(_report(cal["href"], _auth(user), s, e)): + for ev in _parse(_report(cal["href"], _auth(user), s, e), "VEVENT", t0=t0): txt = f"{ev.summary.value if hasattr(ev,'summary') else ''} {ev.description.value if hasattr(ev,'description') else ''} {ev.location.value if hasattr(ev,'location') else ''}".lower() if q in txt: results.append(_fmt_ev(ev, cal["name"])) - return "\n\n".join(results[:30]) if results else "Keine Treffer" + if (time.monotonic() - t0) > PARSE_DEADLINE_S: + truncated = True; break + out = "\n\n".join(results[:30]) if results else "Keine Treffer" + if truncated or len(results) > 30: + out += "\n\n[Begrenzt (Top 30 / Zeit-Deadline) — Zeitraum oder Begriff praezisieren]" + return out @mcp.tool() @@ -182,11 +201,16 @@ def get_tasks( if not user: return "Error: not authenticated" lists = _discover(user, "VTODO") if task_list: lists = [l for l in lists if task_list.lower() in l["name"].lower()] + t0 = time.monotonic() results = [] for lst in lists: - for t in _parse(_report_tasks(lst["href"], _auth(user), include_completed), "VTODO"): + for t in _parse(_report_tasks(lst["href"], _auth(user), include_completed), "VTODO", t0=t0, cap=MAX_RESULTS - len(results)): results.append(_fmt_task(t, lst["name"])) - return "\n\n".join(results) if results else "Keine Aufgaben" + if len(results) >= MAX_RESULTS or (time.monotonic() - t0) > PARSE_DEADLINE_S: break + out = "\n\n".join(results) if results else "Keine Aufgaben" + if len(results) >= MAX_RESULTS: + out += f"\n\n[Nur {MAX_RESULTS} Aufgaben gezeigt]" + return out def _geocode(address): diff --git a/contacts/server.py b/contacts/server.py index cc66fd1..00740ea 100644 --- a/contacts/server.py +++ b/contacts/server.py @@ -4,6 +4,7 @@ import os, sys, re, contextlib, uuid, base64 from typing import Annotated import httpx, vobject +import time from pydantic import Field from mcp.server.fastmcp import FastMCP from mcp.types import TextContent, ImageContent @@ -38,11 +39,15 @@ def _discover_ab(user): books.append({"name": nm.group(1) if nm else href.split("/")[-2], "href": href}) return books -def _get_contacts(href, auth): +PARSE_DEADLINE_S = 12 # Wall-Clock-Limit fuer client-seitiges vobject-Parsen + + +def _get_contacts(href, auth, t0=None): body = '' r = httpx.request("REPORT", RADICALE + href, content=body, auth=auth, headers={"Depth": "1", "Content-Type": "application/xml"}, timeout=60) contacts = [] for m in re.finditer(r'<(?:c|CR):address-data[^>]*>(.*?)', r.text, re.DOTALL): + if t0 is not None and (time.monotonic() - t0) > PARSE_DEADLINE_S: break raw = m.group(1).replace("<","<").replace(">",">").replace("&","&") try: contacts.append(vobject.readOne(raw)) except: pass @@ -98,14 +103,22 @@ def search_contacts( user = get_current_user() if not user: return "Error: not authenticated" q = query.lower() + t0 = time.monotonic() results = [] + truncated = False for book in _discover_ab(user): - for c in _get_contacts(book["href"], _auth(user)): + for c in _get_contacts(book["href"], _auth(user), t0=t0): txt = " ".join(str(getattr(c, a, None) or '') for a in ['fn','email','tel','org','note','nickname']).lower() if q in txt: results.append(_brief(c)) if len(results) >= limit: break - return "\n\n".join(results) if results else "Keine Kontakte gefunden" + if len(results) >= limit: break + if (time.monotonic() - t0) > PARSE_DEADLINE_S: + truncated = True; break + out = "\n\n".join(results) if results else "Keine Kontakte gefunden" + if truncated: + out += "\n\n[Suche zeitbegrenzt — Begriff praezisieren]" + return out def _get_photo_data(card): diff --git a/files/server.py b/files/server.py index 7c08ee8..99c4e0d 100644 --- a/files/server.py +++ b/files/server.py @@ -5,6 +5,7 @@ from xml.etree import ElementTree as ET from typing import Annotated import httpx +import time from pydantic import Field from mcp.server.fastmcp import FastMCP from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents @@ -145,18 +146,31 @@ def file_info( return "\n".join(parts) +SEARCH_DEADLINE_S = 20 # Wall-Clock-Limit fuer search_files +SEARCH_MAX_DIRS = 400 # max Verzeichnisse (PROPFINDs) pro Suche + + @mcp.tool() def search_files( query: Annotated[str, Field(description="Search term — matches file names. Example: 'Rechnung', '.pdf', 'backup'")], path: Annotated[str, Field(description="Start directory for search")] = "/", ) -> str: - """Search for files by name recursively (up to 5 levels deep, max 50 results).""" + """Search for files by name recursively (max 5 Ebenen, 50 Treffer; bricht nach + SEARCH_MAX_DIRS Ordnern bzw. SEARCH_DEADLINE_S ab, damit der Server nie haengt). + Bei grossem Baum/vielen Treffern: 'path' enger setzen.""" user = get_current_user() if not user: return "Error: not authenticated" q = query.lower() results = [] + state = {"dirs": 0, "truncated": False} + t0 = time.monotonic() def _s(p, d=0): - if d > 5 or len(results) >= 50: return + if d > 5 or len(results) >= 50 or state["truncated"]: + return + if state["dirs"] >= SEARCH_MAX_DIRS or (time.monotonic() - t0) > SEARCH_DEADLINE_S: + state["truncated"] = True + return + state["dirs"] += 1 xml, _ = _propfind(user, p) for e in _parse_pf(xml, user)[1:]: if q in e["name"].lower(): results.append(e) @@ -166,7 +180,10 @@ def search_files( for e in results: prefix = "[DIR]" if e["is_dir"] else f"({e['size']:,}b)" lines.append(f"{prefix} {e['path']}") - return "\n".join(lines) if lines else "Keine Dateien gefunden" + out = "\n".join(lines) if lines else "Keine Dateien gefunden" + if state["truncated"]: + out += f"\n\n[Suche begrenzt nach {state['dirs']} Ordnern/{SEARCH_DEADLINE_S}s — Begriff praezisieren oder 'path' enger setzen]" + return out @mcp.tool() diff --git a/mail/server.py b/mail/server.py index e164ea8..af42387 100644 --- a/mail/server.py +++ b/mail/server.py @@ -8,10 +8,11 @@ import contextlib import imaplib import mailbox import gc +import time import ctypes from email.header import decode_header from email.mime.text import MIMEText -from email.utils import formatdate +from email.utils import formatdate, parsedate_to_datetime from pathlib import Path from typing import Annotated @@ -170,6 +171,32 @@ def _open_folder(acct_path, folder_name): return mailbox.Maildir(path, create=False) if os.path.isdir(path) else None +MAX_SCAN = 2000 # Obergrenze gesammelter Treffer (Speicher-Schutz bei breiten Queries) +DEADLINE_S = 120 # Wall-Clock-Limit: Server heilt sich selbst, nie wieder endloser Haenger + + +def _iter_messages(md): + """Yield (key, msg); ueberspringt unparsbare Mails statt die Suche zu killen.""" + try: + keys = list(md.keys()) + except Exception: + return + for key in keys: + try: + msg = md.get_message(key) + except Exception: + continue + yield key, msg + + +def _date_sortkey(date_str): + try: + dt = parsedate_to_datetime(date_str) + return dt.timestamp() if dt else 0.0 + except Exception: + return 0.0 + + @mcp.tool() def list_accounts() -> str: """List all email accounts with their folder count. Call this first to see available accounts.""" @@ -209,7 +236,9 @@ def search_mail( if not user: return "Error: not authenticated" query_lower = query.lower() - results = [] + matches = [] + truncated = False + t0 = time.monotonic() for acct_name, acct_path in _discover_accounts(user).items(): if account and account not in acct_name: continue @@ -219,21 +248,42 @@ def search_mail( md = _open_folder(acct_path, fld) if not md: continue - for key, msg in md.items(): - subj = _decode_hdr(msg.get("Subject", "")) - frm = _decode_hdr(msg.get("From", "")) - to = _decode_hdr(msg.get("To", "")) - date_str = msg.get("Date", "") - if query_lower not in f"{subj} {frm} {to}".lower(): - body = _get_body(msg) - if query_lower not in body.lower(): - continue - results.append(f"[{date_str}] {frm} -> {to}\n Subject: {subj}\n Account: {acct_name}, Folder: {fld}, Key: {key}") - if len(results) >= limit: - _trim_memory() - return "\n\n".join(results) + for key, msg in _iter_messages(md): + try: + subj = _decode_hdr(msg.get("Subject", "")) + frm = _decode_hdr(msg.get("From", "")) + to = _decode_hdr(msg.get("To", "")) + date_str = msg.get("Date", "") + if query_lower not in f"{subj} {frm} {to}".lower(): + if query_lower not in _get_body(msg).lower(): + continue + matches.append((date_str, frm, to, subj, acct_name, fld, key)) + except Exception: + continue + if len(matches) >= MAX_SCAN: + truncated = True + break + if time.monotonic() - t0 > DEADLINE_S: + truncated = True + break + if truncated: + break + if truncated: + break + matches.sort(key=lambda m: _date_sortkey(m[0]), reverse=True) + shown = matches[:limit] _trim_memory() - return "\n\n".join(results) if results else "No results found" + if not shown: + return "No results found" + lines = [f"[{d}] {f} -> {t}\n Subject: {s}\n Account: {a}, Folder: {fl}, Key: {k}" + for (d, f, t, s, a, fl, k) in shown] + more = len(matches) - len(shown) + if more > 0: + if truncated: + lines.append(f"... mindestens {more} weitere Treffer (Scan-Limit erreicht; Suche eingrenzen).") + else: + lines.append(f"... {more} weitere Treffer (Suche eingrenzen oder limit erhoehen).") + return "\n\n".join(lines) @mcp.tool() diff --git a/tests/MCPTEST.md b/tests/MCPTEST.md index 7e5bf36..c3ab04d 100644 --- a/tests/MCPTEST.md +++ b/tests/MCPTEST.md @@ -105,3 +105,63 @@ Gemeinsames Modul `pdfutil.py` wird von Files-MCP (`read_file`) UND Mail-MCP (`read_attachment`) genutzt -> Scan-PDF-Mailanhaenge werden genauso gerendert. Produktiv-Feature (alle User). Test: `TestFileTypes` scanned.pdf -> `image`. Runtime-Deps: `pymupdf`, `pytesseract` + System `tesseract-ocr`/`-deu` (s. `requirements-extra.txt`). + +## Test-Schichten (2026-06-24) + +Drei Schichten, NUR Smoke laeuft naechtlich: + +- **Smoke** (`test_smoke.py`, Marker `smoke`): NIGHTLY via `mcp-tests.timer` (05:00) -> + `run_tests.sh` -> `pytest test_smoke.py`. Pro Connector: Server up + OAuth + tools/list + + EIN read-only Call. Schnell (~0.5s), schreibt keine Testdaten. Faengt Totalausfaelle ab. +- **Edge** (`test_edge.py`, Marker `edge`): ON-DEMAND. Boese/ungewoehnliche Eingaben pro + Connector: Unicode/Emoji-Roundtrips, Sonderzeichen, riesige Limits/Bodies, kaputte + Datumsangaben, **Path-Traversal-Block** (Files). Muss graceful sein, nichts leaken/crashen. +- **Stress** (`test_stress.py`, Marker `stress`): ON-DEMAND. 40-60 gleichzeitige Requests + pro Connector + Mischlast ueber alle 5; danach Responsiveness-Check. Faengt den Klassiker + "single-threaded Server haengt unter Last" (genau der Mail-Haenger 2026-06-23). +- **Funktional** (`test_all.py`): CRUD + OAuth + Datei-Typen/-Edge. ON-DEMAND. + +Helper (`SERVERS`, `get_token`, `tool_call`, `mcp_call`) liegen in `test_all.py`; smoke/edge/ +stress importieren sie. Marker in `conftest.py`. + +**Runner:** +- Nightly (Smoke): automatisch via Timer, ODER `run_tests.sh`. +- On-demand (Funktional+Edge+Stress): `run_full_tests.sh` (Log `/var/log/mcp-tests-full.log`), + oder eine Schicht: `run_full_tests.sh -m stress` (bzw. `-m edge`). +- Log `/var/log/mcp-tests.log` muss `stefan:stefan` gehoeren (Service laeuft als User=stefan). + +## Mail-Server-Haertung (2026-06-23/24) + +`mcp-mail` hing 24h an einem Such-`CallToolRequest` (single-threaded -> Connector tot). +Ursache: eine kaputte Mail (charset `x-unknown` -> `LookupError`) plus fehlende per-Mail- +Fehlerbehandlung. Fix in BEIDEN Servern (`/opt/mcp-servers/mail/server.py` remote, +`/opt/mcp-mail/server.py` lokal fuer Claude Code): +- `_safe_decode` (unbekannte Charsets), `_iter_messages` (per-Mail try/except statt + `md.items()`), per-Treffer try/except -> eine kaputte Mail killt die Suche NICHT mehr + (findet so auch uralte Mails nach der kaputten). +- Vollscan + Datum-Sortierung (neueste zuerst) statt frueher Abbruch bei `limit`. +- `MAX_SCAN=2000` + `DEADLINE_S=120` Wall-Clock -> Server heilt sich selbst, nie wieder + Endlos-Haenger. Volltext-Scan ueber die echten 3.3GB dauert ~60-70s fuer seltene Begriffe; + per `account`/`folder` eingrenzen ist deutlich schneller. + +## Connector-Skalierung (Stand 2026-06-24, gegen ECHTE Daten gemessen) + +Frage: verkraften die Connector "viel durchwuehlen" wie der Mail-Vollscan? +- **Notes:** unkritisch. Joplin server-seitige FTS (`/search`), paginiert mit Cap (max 50 + Seiten), Timeouts. Alle Ops <0.1s auch bei vielen Notizen. +- **Files:** `list_files`/`read_file` ok (1 PROPFIND bzw. 25MB-Cap, Timeouts). ABER + `search_files` war riskant: client-seitige Rekursion = **1 PROPFIND pro Ordner**, kein + Deadline. Real gemessen: Miss-Suche 35s/449 PROPFINDs, wachsend mit dem Baum -> haette + den single-threaded Server blockiert. **Fix:** `SEARCH_DEADLINE_S=20` + `SEARCH_MAX_DIRS=400` + + Truncation-Hinweis ("Begriff praezisieren / 'path' enger"). Worst-Case jetzt ~20s gekappt. + Langfristig besser: oCIS server-seitige Suche (Graph/REPORT) statt Client-Rekursion. +- **Calendar (Radicale):** `get_events` ist server-seitig zeitgefiltert (gut). ABER bei + ~6000 Test-Events gemessen: `search_events` 6-8s (vobject parst ALLE Events im ~15-Mon- + Fenster client-seitig), `get_events` lieferte **10939 Zeilen** (KEIN Result-Limit -> Token- + Explosion). Real schon 5s bei 29 Kalendern. **Fix:** `_parse` mit `PARSE_DEADLINE_S=12` + + `get_events`/`get_tasks` Output-Cap `MAX_RESULTS=300` + Hinweise; `search_events` Top-30 + + Deadline. Danach get_events 1501 statt 10939 Zeilen. +- **Contacts (Radicale):** `search_contacts` parst ALLE vCards client-seitig (real 0.2s, wenige + Kontakte; skaliert linear). **Fix:** `PARSE_DEADLINE_S=12` in `_get_contacts` + Hinweis. +- Rest-Kosten = CalDAV/CardDAV-REPORT-Transfer selbst (kein Server-Result-Limit im Protokoll); + in der Praxis durch Datumsbereich/Begriff eingrenzbar. Langfristig: server-seitige Suche. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9fba41e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Pytest-Konfiguration: Marker fuer die MCP-Test-Schichten. + +- smoke : schnelle nightly Tests (Server up + OAuth + 1 Read pro Connector) +- edge : Edge-Case-Tests (on-demand, NICHT naechtlich) +- stress : Last-/Concurrency-Tests (on-demand, NICHT naechtlich) +""" + + +def pytest_configure(config): + config.addinivalue_line("markers", "smoke: schnelle nightly Smoke-Tests") + config.addinivalue_line("markers", "edge: Edge-Case-Tests (on-demand)") + config.addinivalue_line("markers", "stress: Last-/Concurrency-Tests (on-demand)") diff --git a/tests/run_full_tests.sh b/tests/run_full_tests.sh new file mode 100644 index 0000000..f9d8cbe --- /dev/null +++ b/tests/run_full_tests.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# MCP Voll-Tests — ON-DEMAND (NICHT naechtlich; manuell ausfuehren). +# Funktional (test_all.py) + Edge-Cases (test_edge.py) + Stress/Concurrency (test_stress.py). +# Schreibt Testdaten in die mcptest-Sandbox (calendar-test, contacts-test, /.mcp-tests*, Inbox). +# +# Nutzung: +# /opt/mcp-servers/tests/run_full_tests.sh # alles +# /opt/mcp-servers/tests/run_full_tests.sh -m stress # nur eine Schicht (pytest-Marker) +set -uo pipefail + +LOG="/var/log/mcp-tests-full.log" +VENV="/opt/mcp-servers/venv/bin" +cd /opt/mcp-servers/tests || exit 1 + +echo "[$(date)] MCP Voll-Tests gestartet" | tee -a "$LOG" + +if [ "$#" -gt 0 ]; then + # z.B. -m smoke | -m edge | -m stress (Marker-basiert ueber alle Dateien) + OUTPUT=$($VENV/python -m pytest test_all.py test_edge.py test_stress.py "$@" -v --tb=short 2>&1) +else + OUTPUT=$($VENV/python -m pytest test_all.py test_edge.py test_stress.py -v --tb=short 2>&1) +fi +EXIT=$? + +echo "$OUTPUT" | tee -a "$LOG" +echo "$OUTPUT" | tail -1 +echo "[$(date)] MCP Voll-Tests fertig (exit $EXIT)" | tee -a "$LOG" +exit $EXIT diff --git a/tests/run_tests.sh b/tests/run_tests.sh index c30198a..81b311d 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -1,25 +1,27 @@ #!/bin/bash -# MCP Server Integration Tests — laeuft taeglich via systemd timer +# MCP Smoke-Tests — laeuft taeglich via systemd timer (mcp-tests.timer 05:00). +# NUR Smoke (Server up + Auth + 1 Read/Connector). Stress/Edge/Funktional laufen +# NICHT naechtlich -> on-demand via run_full_tests.sh. set -uo pipefail LOG="/var/log/mcp-tests.log" NTFY_TOPIC="admin" VENV="/opt/mcp-servers/venv/bin" -echo "[$(date)] MCP Tests gestartet" | tee -a "$LOG" +echo "[$(date)] MCP Smoke-Tests gestartet" | tee -a "$LOG" -OUTPUT=$($VENV/python -m pytest /opt/mcp-servers/tests/test_all.py -v --tb=short 2>&1) +OUTPUT=$(cd /opt/mcp-servers/tests && $VENV/python -m pytest test_smoke.py -v --tb=short 2>&1) EXIT=$? echo "$OUTPUT" | tee -a "$LOG" if [ $EXIT -eq 0 ]; then PASSED=$(echo "$OUTPUT" | grep -oP '\d+ passed' | head -1) - echo "[$(date)] MCP Tests OK: $PASSED" | tee -a "$LOG" + echo "[$(date)] MCP Smoke-Tests OK: $PASSED" | tee -a "$LOG" else FAILED=$(echo "$OUTPUT" | grep "FAILED" | head -5) - echo "[$(date)] MCP Tests FEHLGESCHLAGEN" | tee -a "$LOG" - /usr/local/bin/notify-ntfy "$NTFY_TOPIC" "MCP Tests fehlgeschlagen" \ + echo "[$(date)] MCP Smoke-Tests FEHLGESCHLAGEN" | tee -a "$LOG" + /usr/local/bin/notify-ntfy "$NTFY_TOPIC" "MCP Smoke-Tests fehlgeschlagen" \ "$(echo "$FAILED" | head -3)\n\nLog: tail -50 $LOG" "urgent" "x,test_tube" fi diff --git a/tests/test_all.py b/tests/test_all.py index 106c4ec..f6452dc 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -148,6 +148,33 @@ class TestMail: {"query": "xyzzy_nonexistent_99", "limit": 1, "account": "mcp-test-empty"}) assert "No results" in text + def test_search_finds_old_mail(self): + # Uralte Mail (2009) muss gefunden werden -> vollstaendiger Scan, kein Crash davor + text = tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "URALTMAIL2009", "limit": 5, "account": "mcp-test-mail"}) + assert "No results" not in text + assert "2009" in text + + def test_search_survives_bad_charset(self): + # Mail mit charset x-unknown darf die Suche NICHT killen und muss findbar sein + text = tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "XUNKNOWNTOKEN", "limit": 5, "account": "mcp-test-mail"}) + assert "No results" not in text + assert "Subject:" in text + + def test_search_survives_bad_header(self): + # Kaputter encoded-word-Header darf decode_hdr nicht crashen + text = tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "BADHEADERTOKEN", "limit": 5, "account": "mcp-test-mail"}) + assert "No results" not in text + + def test_search_broad_no_crash(self): + # Breite Suche scannt ueber die Edge-Mails hinweg ohne Fehler + text = tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "mcptest", "limit": 50, "account": "mcp-test-mail"}) + assert "Error" not in text + assert text.count("Subject:") >= 2 + def test_read_mail(self): # First search to get a key text = tool_call(self.PORT, get_token(self.PORT), "search_mail", @@ -298,6 +325,25 @@ class TestContacts: assert "Mustermann" in text assert "UID:" in text + def test_set_and_read_photo(self): + # Foto setzen + zuruecklesen (get_contact liefert dann ImageContent) + token = get_token(self.PORT) + tag = f"PhotoTest{int(time.time())}" + tool_call(self.PORT, token, "create_contact", {"name": f"{tag} Bildmann", "email": f"{tag.lower()}@example.com"}) + search = tool_call(self.PORT, token, "search_contacts", {"query": tag, "limit": 1}) + uid = [l for l in search.split("\n") if "UID:" in l][0].split("UID:")[1].strip() + # 1x1 PNG + png_b64 = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC" + "AAAAC0lEQVR42mNk+M8AAAMCAYAAAS+ZkAAAAABJRU5ErkJggg==") + res = tool_call(self.PORT, token, "set_contact_photo", + {"uid": uid, "image_base64": png_b64, "image_type": "png"}) + assert "esetzt" in res.lower() or "set" in res.lower() + content = mcp_call(self.PORT, token, "tools/call", + {"name": "get_contact", "arguments": {"uid": uid}})["result"]["content"] + imgs = [c for c in content if c.get("type") == "image"] + assert imgs, f"Kein Bild in get_contact: {[c.get('type') for c in content]}" + assert imgs[0].get("mimeType", "").startswith("image/") + # ============================================================ # Files Tests (CRUD on /mcp-tests/) diff --git a/tests/test_edge.py b/tests/test_edge.py new file mode 100644 index 0000000..4e988a3 --- /dev/null +++ b/tests/test_edge.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""MCP Edge-Case-Tests — ON-DEMAND (nicht naechtlich). + +Pro Connector: ungewoehnliche/boese Eingaben (Unicode/Emoji, Sonderzeichen, +riesige Limits/Bodies, kaputte Datumsangaben, Path-Traversal). Ziel: der Server +faengt das sauber ab (graceful) statt zu crashen oder Daten zu leaken. + +Lauf: /opt/mcp-servers/venv/bin/python -m pytest test_edge.py -q +""" + +import time +import pytest + +from test_all import SERVERS, get_token, mcp_call, tool_call + +pytestmark = pytest.mark.edge + +EMOJI = "Zürdäß 🚀😀 漢字 مرحبا" + + +def _alive(port): + """Server beantwortet nach einer boesen Eingabe noch normal.""" + res = mcp_call(port, get_token(port), "tools/list") + assert res and res.get("result", {}).get("tools"), f"Server {port} nach Edge-Call tot" + + +# ============================================================ +# Mail +# ============================================================ + +class TestMailEdge: + PORT = SERVERS["mail"] + + def test_huge_limit(self): + tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "mcptest", "limit": 100000, "account": "mcp-test-mail"}) + _alive(self.PORT) + + def test_regex_special_chars_literal(self): + for q in [".*", "[a-z]+", "%27 OR 1=1", "a\\b", "()|"]: + tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": q, "limit": 3, "account": "mcp-test-mail"}) + _alive(self.PORT) + + def test_very_long_query(self): + tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "x" * 2000, "limit": 3, "account": "mcp-test-mail"}) + _alive(self.PORT) + + def test_nonexistent_account_filter(self): + text = tool_call(self.PORT, get_token(self.PORT), "search_mail", + {"query": "mcptest", "account": "does-not-exist-xyz"}) + assert "No results" in text or text.strip() == "" or "Subject:" not in text + _alive(self.PORT) + + +# ============================================================ +# Calendar +# ============================================================ + +class TestCalendarEdge: + PORT = SERVERS["calendar"] + CAL = "calendar-test" + + def test_invalid_date_graceful(self): + # kaputtes Datum darf den Server nicht killen + mcp_call(self.PORT, get_token(self.PORT), "tools/call", { + "name": "create_event", + "arguments": {"calendar": self.CAL, "title": "BadDate", "start": "kein-datum", "end": "32.13.2026"}, + }) + _alive(self.PORT) + + def test_unicode_emoji_event(self): + token = get_token(self.PORT) + tag = f"edge-uni-{int(time.time())}" + tool_call(self.PORT, token, "create_event", { + "calendar": self.CAL, "title": f"{EMOJI} {tag}", + "start": "2027-01-02T10:00", "end": "2027-01-02T11:00", + }) + found = tool_call(self.PORT, token, "search_events", {"query": tag}) + assert tag in found + + def test_reversed_date_range(self): + tool_call(self.PORT, get_token(self.PORT), "get_events", + {"calendar": self.CAL, "date_from": "2027-12-31", "date_to": "2027-01-01"}) + _alive(self.PORT) + + +# ============================================================ +# Contacts +# ============================================================ + +class TestContactsEdge: + PORT = SERVERS["contacts"] + + def test_unicode_emoji_contact(self): + token = get_token(self.PORT) + tag = f"EdgeUni{int(time.time())}" + tool_call(self.PORT, token, "create_contact", + {"name": f"{tag} {EMOJI}", "email": f"{tag.lower()}@example.com"}) + found = tool_call(self.PORT, token, "search_contacts", {"query": tag}) + assert tag in found + + def test_special_char_search(self): + for q in ["O'Brien", "müller & söhne", "100% sicher", "