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) <noreply@anthropic.com>
This commit is contained in:
Stefan Lohmaier
2026-06-24 21:17:26 +02:00
co-authored by Claude Opus 4.8
parent 936ebc2f56
commit c9ccb4392b
26 changed files with 719 additions and 65 deletions
+1
View File
@@ -7,3 +7,4 @@ config.json
*.before-* *.before-*
.token_audit.log .token_audit.log
*.bak-* *.bak-*
*.bak.*
+31 -7
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Annotated from typing import Annotated
import httpx, vobject import httpx, vobject
import time
from pydantic import Field from pydantic import Field
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette from starlette.applications import Starlette
@@ -58,9 +59,15 @@ def _report_tasks(href, auth, inc=False):
body = f'<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR">{filt}</c:comp-filter></c:filter></c:calendar-query>' body = f'<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR">{filt}</c:comp-filter></c:filter></c:calendar-query>'
return httpx.request("REPORT", RADICALE + href, content=body, auth=auth, headers={"Depth": "1", "Content-Type": "application/xml"}, timeout=30).text 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 = [] objs = []
for m in re.finditer(r'<(?:c|C):calendar-data[^>]*>(.*?)</(?:c|C):calendar-data>', xml, re.DOTALL): for m in re.finditer(r'<(?:c|C):calendar-data[^>]*>(.*?)</(?: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("&lt;","<").replace("&gt;",">").replace("&amp;","&") raw = m.group(1).replace("&lt;","<").replace("&gt;",">").replace("&amp;","&")
try: try:
for c in vobject.readOne(raw).components(): 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." if not cals: return f"Kalender '{calendar}' nicht gefunden. Nutze list_calendars."
s = datetime.strptime(date_from, "%Y-%m-%d").strftime("%Y%m%dT000000Z") s = datetime.strptime(date_from, "%Y-%m-%d").strftime("%Y%m%dT000000Z")
e = datetime.strptime(date_to, "%Y-%m-%d").strftime("%Y%m%dT235959Z") e = datetime.strptime(date_to, "%Y-%m-%d").strftime("%Y%m%dT235959Z")
t0 = time.monotonic()
results = [] results = []
for cal in cals: 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"])) 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() @mcp.tool()
@@ -154,13 +166,20 @@ def search_events(
s = datetime.strptime(date_from, "%Y-%m-%d").strftime("%Y%m%dT000000Z") s = datetime.strptime(date_from, "%Y-%m-%d").strftime("%Y%m%dT000000Z")
e = datetime.strptime(date_to, "%Y-%m-%d").strftime("%Y%m%dT235959Z") e = datetime.strptime(date_to, "%Y-%m-%d").strftime("%Y%m%dT235959Z")
q = query.lower() q = query.lower()
t0 = time.monotonic()
results = [] results = []
truncated = False
for cal in _discover(user, "VEVENT"): 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() 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: if q in txt:
results.append(_fmt_ev(ev, cal["name"])) 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() @mcp.tool()
@@ -182,11 +201,16 @@ def get_tasks(
if not user: return "Error: not authenticated" if not user: return "Error: not authenticated"
lists = _discover(user, "VTODO") lists = _discover(user, "VTODO")
if task_list: lists = [l for l in lists if task_list.lower() in l["name"].lower()] if task_list: lists = [l for l in lists if task_list.lower() in l["name"].lower()]
t0 = time.monotonic()
results = [] results = []
for lst in lists: 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"])) 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): def _geocode(address):
+16 -3
View File
@@ -4,6 +4,7 @@ import os, sys, re, contextlib, uuid, base64
from typing import Annotated from typing import Annotated
import httpx, vobject import httpx, vobject
import time
from pydantic import Field from pydantic import Field
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent 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}) books.append({"name": nm.group(1) if nm else href.split("/")[-2], "href": href})
return books 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 = '<?xml version="1.0"?><c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop></c:addressbook-query>' body = '<?xml version="1.0"?><c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop></c:addressbook-query>'
r = httpx.request("REPORT", RADICALE + href, content=body, auth=auth, headers={"Depth": "1", "Content-Type": "application/xml"}, timeout=60) r = httpx.request("REPORT", RADICALE + href, content=body, auth=auth, headers={"Depth": "1", "Content-Type": "application/xml"}, timeout=60)
contacts = [] contacts = []
for m in re.finditer(r'<(?:c|CR):address-data[^>]*>(.*?)</(?:c|CR):address-data>', r.text, re.DOTALL): for m in re.finditer(r'<(?:c|CR):address-data[^>]*>(.*?)</(?: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("&lt;","<").replace("&gt;",">").replace("&amp;","&") raw = m.group(1).replace("&lt;","<").replace("&gt;",">").replace("&amp;","&")
try: contacts.append(vobject.readOne(raw)) try: contacts.append(vobject.readOne(raw))
except: pass except: pass
@@ -98,14 +103,22 @@ def search_contacts(
user = get_current_user() user = get_current_user()
if not user: return "Error: not authenticated" if not user: return "Error: not authenticated"
q = query.lower() q = query.lower()
t0 = time.monotonic()
results = [] results = []
truncated = False
for book in _discover_ab(user): 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() txt = " ".join(str(getattr(c, a, None) or '') for a in ['fn','email','tel','org','note','nickname']).lower()
if q in txt: if q in txt:
results.append(_brief(c)) results.append(_brief(c))
if len(results) >= limit: break 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): def _get_photo_data(card):
+20 -3
View File
@@ -5,6 +5,7 @@ from xml.etree import ElementTree as ET
from typing import Annotated from typing import Annotated
import httpx import httpx
import time
from pydantic import Field from pydantic import Field
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents
@@ -145,18 +146,31 @@ def file_info(
return "\n".join(parts) 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() @mcp.tool()
def search_files( def search_files(
query: Annotated[str, Field(description="Search term — matches file names. Example: 'Rechnung', '.pdf', 'backup'")], query: Annotated[str, Field(description="Search term — matches file names. Example: 'Rechnung', '.pdf', 'backup'")],
path: Annotated[str, Field(description="Start directory for search")] = "/", path: Annotated[str, Field(description="Start directory for search")] = "/",
) -> str: ) -> 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() user = get_current_user()
if not user: return "Error: not authenticated" if not user: return "Error: not authenticated"
q = query.lower() q = query.lower()
results = [] results = []
state = {"dirs": 0, "truncated": False}
t0 = time.monotonic()
def _s(p, d=0): 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) xml, _ = _propfind(user, p)
for e in _parse_pf(xml, user)[1:]: for e in _parse_pf(xml, user)[1:]:
if q in e["name"].lower(): results.append(e) if q in e["name"].lower(): results.append(e)
@@ -166,7 +180,10 @@ def search_files(
for e in results: for e in results:
prefix = "[DIR]" if e["is_dir"] else f"({e['size']:,}b)" prefix = "[DIR]" if e["is_dir"] else f"({e['size']:,}b)"
lines.append(f"{prefix} {e['path']}") 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() @mcp.tool()
+60 -10
View File
@@ -8,10 +8,11 @@ import contextlib
import imaplib import imaplib
import mailbox import mailbox
import gc import gc
import time
import ctypes import ctypes
from email.header import decode_header from email.header import decode_header
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formatdate from email.utils import formatdate, parsedate_to_datetime
from pathlib import Path from pathlib import Path
from typing import Annotated 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 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() @mcp.tool()
def list_accounts() -> str: def list_accounts() -> str:
"""List all email accounts with their folder count. Call this first to see available accounts.""" """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: if not user:
return "Error: not authenticated" return "Error: not authenticated"
query_lower = query.lower() query_lower = query.lower()
results = [] matches = []
truncated = False
t0 = time.monotonic()
for acct_name, acct_path in _discover_accounts(user).items(): for acct_name, acct_path in _discover_accounts(user).items():
if account and account not in acct_name: if account and account not in acct_name:
continue continue
@@ -219,21 +248,42 @@ def search_mail(
md = _open_folder(acct_path, fld) md = _open_folder(acct_path, fld)
if not md: if not md:
continue continue
for key, msg in md.items(): for key, msg in _iter_messages(md):
try:
subj = _decode_hdr(msg.get("Subject", "")) subj = _decode_hdr(msg.get("Subject", ""))
frm = _decode_hdr(msg.get("From", "")) frm = _decode_hdr(msg.get("From", ""))
to = _decode_hdr(msg.get("To", "")) to = _decode_hdr(msg.get("To", ""))
date_str = msg.get("Date", "") date_str = msg.get("Date", "")
if query_lower not in f"{subj} {frm} {to}".lower(): if query_lower not in f"{subj} {frm} {to}".lower():
body = _get_body(msg) if query_lower not in _get_body(msg).lower():
if query_lower not in body.lower():
continue continue
results.append(f"[{date_str}] {frm} -> {to}\n Subject: {subj}\n Account: {acct_name}, Folder: {fld}, Key: {key}") matches.append((date_str, frm, to, subj, acct_name, fld, key))
if len(results) >= limit: 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() _trim_memory()
return "\n\n".join(results) if not shown:
_trim_memory() return "No results found"
return "\n\n".join(results) if results else "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() @mcp.tool()
+60
View File
@@ -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. (`read_attachment`) genutzt -> Scan-PDF-Mailanhaenge werden genauso gerendert.
Produktiv-Feature (alle User). Test: `TestFileTypes` scanned.pdf -> `image`. Produktiv-Feature (alle User). Test: `TestFileTypes` scanned.pdf -> `image`.
Runtime-Deps: `pymupdf`, `pytesseract` + System `tesseract-ocr`/`-deu` (s. `requirements-extra.txt`). 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.
+12
View File
@@ -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)")
+28
View File
@@ -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
+8 -6
View File
@@ -1,25 +1,27 @@
#!/bin/bash #!/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 set -uo pipefail
LOG="/var/log/mcp-tests.log" LOG="/var/log/mcp-tests.log"
NTFY_TOPIC="admin" NTFY_TOPIC="admin"
VENV="/opt/mcp-servers/venv/bin" 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=$? EXIT=$?
echo "$OUTPUT" | tee -a "$LOG" echo "$OUTPUT" | tee -a "$LOG"
if [ $EXIT -eq 0 ]; then if [ $EXIT -eq 0 ]; then
PASSED=$(echo "$OUTPUT" | grep -oP '\d+ passed' | head -1) 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 else
FAILED=$(echo "$OUTPUT" | grep "FAILED" | head -5) FAILED=$(echo "$OUTPUT" | grep "FAILED" | head -5)
echo "[$(date)] MCP Tests FEHLGESCHLAGEN" | tee -a "$LOG" echo "[$(date)] MCP Smoke-Tests FEHLGESCHLAGEN" | tee -a "$LOG"
/usr/local/bin/notify-ntfy "$NTFY_TOPIC" "MCP Tests fehlgeschlagen" \ /usr/local/bin/notify-ntfy "$NTFY_TOPIC" "MCP Smoke-Tests fehlgeschlagen" \
"$(echo "$FAILED" | head -3)\n\nLog: tail -50 $LOG" "urgent" "x,test_tube" "$(echo "$FAILED" | head -3)\n\nLog: tail -50 $LOG" "urgent" "x,test_tube"
fi fi
+46
View File
@@ -148,6 +148,33 @@ class TestMail:
{"query": "xyzzy_nonexistent_99", "limit": 1, "account": "mcp-test-empty"}) {"query": "xyzzy_nonexistent_99", "limit": 1, "account": "mcp-test-empty"})
assert "No results" in text 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): def test_read_mail(self):
# First search to get a key # First search to get a key
text = tool_call(self.PORT, get_token(self.PORT), "search_mail", text = tool_call(self.PORT, get_token(self.PORT), "search_mail",
@@ -298,6 +325,25 @@ class TestContacts:
assert "Mustermann" in text assert "Mustermann" in text
assert "UID:" 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/) # Files Tests (CRUD on /mcp-tests/)
+183
View File
@@ -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", "<script>"]:
tool_call(self.PORT, get_token(self.PORT), "search_contacts", {"query": q})
_alive(self.PORT)
def test_very_long_name(self):
token = get_token(self.PORT)
tag = f"EdgeLong{int(time.time())}"
tool_call(self.PORT, token, "create_contact",
{"name": f"{tag} " + ("Lang" * 200), "email": f"{tag.lower()}@example.com"})
_alive(self.PORT)
# ============================================================
# Files
# ============================================================
class TestFilesEdge:
PORT = SERVERS["files"]
DIR = "/.mcp-tests-edge"
@pytest.fixture(autouse=True, scope="class")
def _dir(self):
token = get_token(self.PORT)
tool_call(self.PORT, token, "create_folder", {"path": self.DIR})
yield
tool_call(self.PORT, token, "delete_file", {"path": self.DIR})
def test_path_traversal_blocked(self):
# darf NIEMALS /etc/passwd ausliefern
for p in ["/../../../../etc/passwd", "/.mcp-tests-edge/../../../etc/passwd"]:
res = mcp_call(self.PORT, get_token(self.PORT), "tools/call",
{"name": "read_file", "arguments": {"path": p}})
text = ""
if res and "result" in res:
text = res["result"]["content"][0].get("text", "")
assert "root:x:0:0" not in text, f"Path-Traversal LEAK bei {p}"
_alive(self.PORT)
def test_unicode_emoji_roundtrip(self):
token = get_token(self.PORT)
path = f"{self.DIR}/edge-{int(time.time())}-{EMOJI[:4]}.txt"
tool_call(self.PORT, token, "write_file", {"path": path, "content": EMOJI})
read = mcp_call(self.PORT, token, "tools/call", {"name": "read_file", "arguments": {"path": path}})
assert "🚀" in read["result"]["content"][0]["text"]
tool_call(self.PORT, token, "delete_file", {"path": path})
def test_nonexistent_deep_path(self):
text = tool_call(self.PORT, get_token(self.PORT), "file_info",
{"path": "/.mcp-tests-edge/nope/nope/nope.txt"})
assert "404" in text or "gefunden" in text.lower() or "not found" in text.lower()
_alive(self.PORT)
# ============================================================
# Notes
# ============================================================
class TestNotesEdge:
PORT = SERVERS["notes"]
NB = "Inbox"
def test_unicode_emoji_markdown_note(self):
token = get_token(self.PORT)
tag = f"edge-uni-{int(time.time())}"
body = f"# {EMOJI} {tag}\n\n- [ ] Aufgabe\n```py\nprint('x')\n```\n| a | b |\n|---|---|\n"
result = tool_call(self.PORT, token, "create_note", {"notebook": self.NB, "title": f"Edge {tag}", "body": body})
note_id = result.split("id: ")[1].rstrip(")").strip()
read = tool_call(self.PORT, token, "read_note", {"note_id": note_id})
assert tag in read and "🚀" in read
def test_large_body(self):
token = get_token(self.PORT)
tag = f"edge-big-{int(time.time())}"
big = (f"Zeile {tag} " + "lorem ipsum " * 8 + "\n") * 800 # ~ hunderte KB
result = tool_call(self.PORT, token, "create_note", {"notebook": self.NB, "title": f"Big {tag}", "body": big})
note_id = result.split("id: ")[1].rstrip(")").strip()
read = tool_call(self.PORT, token, "read_note", {"note_id": note_id})
assert tag in read
_alive(self.PORT)
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""MCP Smoke-Tests — NIGHTLY.
Minimal, schnell, read-only. Pro Connector: Server erreichbar, OAuth ok,
tools/list ok, ein billiger Read-Call. Faengt Totalausfaelle (Server down,
Auth kaputt, Backend weg) ueber Nacht ab, ohne Last zu erzeugen oder Testdaten
zu schreiben.
Lauf: /opt/mcp-servers/venv/bin/python -m pytest test_smoke.py -q
"""
import pytest
from test_all import SERVERS, get_token, mcp_call, tool_call
pytestmark = pytest.mark.smoke
@pytest.mark.parametrize("svc,port", list(SERVERS.items()))
def test_server_up_auth_tools(svc, port):
"""Server erreichbar + OAuth (client_credentials) + tools/list liefert Tools."""
token = get_token(port)
assert token, f"{svc}: kein Token"
res = mcp_call(port, token, "tools/list")
assert res and res.get("result", {}).get("tools"), f"{svc}: tools/list leer"
# --- ein billiger Read-Call pro Connector (read-only, kein Schreiben) ---
def test_mail_read():
assert "mcp-test-mail" in tool_call(SERVERS["mail"], get_token(SERVERS["mail"]), "list_accounts")
def test_calendar_read():
text = tool_call(SERVERS["calendar"], get_token(SERVERS["calendar"]), "list_calendars")
assert "calendar-test" in text or "MCP Test" in text
def test_contacts_read():
# read-only Suche; Treffer egal, Hauptsache der Call kommt sauber durch
text = tool_call(SERVERS["contacts"], get_token(SERVERS["contacts"]),
"search_contacts", {"query": "Mustermann", "limit": 1})
assert isinstance(text, str) and len(text) >= 0
def test_files_read():
text = tool_call(SERVERS["files"], get_token(SERVERS["files"]), "list_files", {"path": "/"})
assert "DIR" in text or "/" in text
def test_notes_read():
text = tool_call(SERVERS["notes"], get_token(SERVERS["notes"]), "list_notebooks")
assert "id:" in text
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""MCP Stress-/Concurrency-Tests — ON-DEMAND (nicht naechtlich).
Feuert viele gleichzeitige Requests pro Connector und prueft: alle kommen sauber
durch (kein Deadlock/Haenger) und der Server ist danach sofort wieder responsiv.
Genau diese Art Test haette den Mail-Server-Haenger (single-threaded, 24h blockiert)
gefangen.
Lauf: /opt/mcp-servers/venv/bin/python -m pytest test_stress.py -q
"""
import time
import random
import concurrent.futures as cf
import pytest
from test_all import SERVERS, get_token, tool_call
pytestmark = pytest.mark.stress
N = 40 # gleichzeitige Requests pro Welle
WORKERS = 16 # parallele Clients
RESPONSIVE_S = 15 # Server muss nach der Last so schnell wieder antworten
def _hammer(fn, n=N):
"""fn() n-mal nebenlaeufig ausfuehren; gibt Liste der Fehlertexte zurueck."""
errors = []
with cf.ThreadPoolExecutor(max_workers=WORKERS) as ex:
futs = [ex.submit(fn) for _ in range(n)]
for f in cf.as_completed(futs):
try:
f.result()
except Exception as e: # noqa: BLE001
errors.append(repr(e)[:200])
return errors
def _assert_responsive(port, probe):
t = time.time()
probe()
dt = time.time() - t
assert dt < RESPONSIVE_S, f"Server {port} nach Last traege: {dt:.1f}s"
class TestStress:
def test_mail_concurrent_search(self):
port = SERVERS["mail"]; token = get_token(port)
queries = ["mcptest", "Rechnung", "stefan", "Willkommen",
"XUNKNOWNTOKEN", "URALTMAIL2009", "BADHEADERTOKEN", "Newsletter"]
errors = _hammer(lambda: tool_call(port, token, "search_mail",
{"query": random.choice(queries), "limit": 5, "account": "mcp-test-mail"}))
assert not errors, f"{len(errors)} Fehler unter Last: {errors[:3]}"
_assert_responsive(port, lambda: tool_call(port, token, "list_accounts"))
def test_calendar_concurrent_reads(self):
port = SERVERS["calendar"]; token = get_token(port)
def call():
if random.random() < 0.5:
tool_call(port, token, "list_calendars")
else:
tool_call(port, token, "get_events",
{"calendar": "calendar-test", "date_from": "2026-01-01", "date_to": "2026-12-31"})
errors = _hammer(call)
assert not errors, f"{len(errors)} Fehler: {errors[:3]}"
_assert_responsive(port, lambda: tool_call(port, token, "list_calendars"))
def test_contacts_concurrent_search(self):
port = SERVERS["contacts"]; token = get_token(port)
qs = ["Mustermann", "Mcp", "example", "xyzzy_nobody", "GmbH"]
errors = _hammer(lambda: tool_call(port, token, "search_contacts",
{"query": random.choice(qs), "limit": 5}))
assert not errors, f"{len(errors)} Fehler: {errors[:3]}"
_assert_responsive(port, lambda: tool_call(port, token, "search_contacts", {"query": "Mustermann", "limit": 1}))
def test_files_concurrent_reads(self):
port = SERVERS["files"]; token = get_token(port)
def call():
if random.random() < 0.5:
tool_call(port, token, "list_files", {"path": "/"})
else:
tool_call(port, token, "file_info", {"path": "/"})
errors = _hammer(call)
assert not errors, f"{len(errors)} Fehler: {errors[:3]}"
_assert_responsive(port, lambda: tool_call(port, token, "list_files", {"path": "/"}))
def test_notes_concurrent_reads(self):
port = SERVERS["notes"]; token = get_token(port)
def call():
if random.random() < 0.5:
tool_call(port, token, "list_notebooks")
else:
tool_call(port, token, "list_notes", {"limit": 5})
errors = _hammer(call)
assert not errors, f"{len(errors)} Fehler: {errors[:3]}"
_assert_responsive(port, lambda: tool_call(port, token, "list_notebooks"))
def test_all_servers_mixed_load(self):
"""Alle 5 Connector gleichzeitig unter Last — Gesamtsystem haelt."""
probes = {
"mail": lambda p, t: tool_call(p, t, "search_mail", {"query": "mcptest", "limit": 3, "account": "mcp-test-mail"}),
"calendar": lambda p, t: tool_call(p, t, "list_calendars"),
"contacts": lambda p, t: tool_call(p, t, "search_contacts", {"query": "Mustermann", "limit": 2}),
"files": lambda p, t: tool_call(p, t, "list_files", {"path": "/"}),
"notes": lambda p, t: tool_call(p, t, "list_notebooks"),
}
tokens = {svc: get_token(SERVERS[svc]) for svc in probes}
def call():
svc = random.choice(list(probes))
probes[svc](SERVERS[svc], tokens[svc])
errors = _hammer(call, n=60)
assert not errors, f"{len(errors)} Fehler im Mischbetrieb: {errors[:3]}"
+27
View File
@@ -46,6 +46,33 @@ add("mcp-test-mail","INBOX", mail_with("voice@example.com","Sprachnachricht","Ku
add("mcp-test-mail","INBOX", mail_with("projekt@example.com","Projektdateien","Archiv und Notizen.",["archive.zip","notes.txt","data.csv"])) add("mcp-test-mail","INBOX", mail_with("projekt@example.com","Projektdateien","Archiv und Notizen.",["archive.zip","notes.txt","data.csv"]))
add("mcp-test-mail","INBOX", mail_with("media@example.com","Praesentation + Clip","Folien und ein kurzes Video.",["slides.pptx","clip.mp4"])) add("mcp-test-mail","INBOX", mail_with("media@example.com","Praesentation + Clip","Folien und ein kurzes Video.",["slides.pptx","clip.mp4"]))
# --- Edge-Cases: Robustheit + uralte Mails (Fix 2026-06-24) ---
def addraw(acct, folder, raw_bytes):
path=os.path.join(BASE,acct,folder); os.makedirs(os.path.dirname(path),exist_ok=True)
mailbox.Maildir(path,create=True).add(raw_bytes)
# Uralte Mail (2009) mit eindeutigem Token -> Suche muss alte Mails finden
_old=simple("archiv@example.com","mcptest@local","Uralte Rechnung URALTMAIL2009","Sehr alte Mail aus 2009 zum Testen der Alt-Mail-Suche.")
_old.replace_header("Date","Tue, 03 Mar 2009 08:15:00 +0100")
add("mcp-test-mail","Archive",_old)
# Mail mit kaputtem Charset x-unknown -> darf die Suche NICHT killen, Token findbar
addraw("mcp-test-mail","INBOX",
b"From: weird@example.com\r\nTo: mcptest@local\r\n"
b"Subject: Kaputtes Encoding XUNKNOWNTOKEN\r\n"
b"Date: Wed, 10 Jan 2018 12:00:00 +0100\r\n"
b'Content-Type: text/plain; charset="x-unknown"\r\n'
b"Content-Transfer-Encoding: 8bit\r\n\r\n"
b"Koerpertext mit kaputtem Charset XUNKNOWNTOKEN und Umlaut \xfc.\r\n")
# Mail mit kaputtem encoded-word-Header -> decode_hdr darf nicht crashen
addraw("mcp-test-mail","INBOX",
b"From: =?invalid-charset?Q?Br=F6tchen?= <bad@example.com>\r\nTo: mcptest@local\r\n"
b"Subject: =?x-unknown?B?a2FwdXR0?= BADHEADERTOKEN\r\n"
b"Date: Fri, 05 Jul 2013 09:00:00 +0200\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n\r\n"
b"Body BADHEADERTOKEN.\r\n")
# new -> cur # new -> cur
for r,d,f in os.walk(BASE): for r,d,f in os.walk(BASE):
if r.endswith("new"): if r.endswith("new"):
@@ -0,0 +1,9 @@
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
From: archiv@example.com
To: mcptest@local
Subject: Uralte Rechnung URALTMAIL2009
Date: Tue, 03 Mar 2009 08:15:00 +0100
U2VociBhbHRlIE1haWwgYXVzIDIwMDkgenVtIFRlc3RlbiBkZXIgQWx0LU1haWwtU3VjaGUu
@@ -1,11 +1,11 @@
Content-Type: multipart/mixed; boundary="===============3363393740883118962==" Content-Type: multipart/mixed; boundary="===============2801843982600110124=="
MIME-Version: 1.0 MIME-Version: 1.0
From: billing@example.com From: billing@example.com
To: mcptest@local To: mcptest@local
Subject: Ihre Rechnung Juni 2026 Subject: Ihre Rechnung Juni 2026
Date: Tue, 17 Jun 2026 10:00:00 +0200 Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============3363393740883118962== --===============2801843982600110124==
Content-Type: text/plain; charset="utf-8" Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -13,7 +13,7 @@ Content-Transfer-Encoding: base64
SW0gQW5oYW5nIGRpZSBSZWNobnVuZyAoVGV4dC1QREYpIHVuZCBkZXIgU2NhbiAoQmlsZC1QREYp SW0gQW5oYW5nIGRpZSBSZWNobnVuZyAoVGV4dC1QREYpIHVuZCBkZXIgU2NhbiAoQmlsZC1QREYp
Lg== Lg==
--===============3363393740883118962== --===============2801843982600110124==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -40,7 +40,7 @@ b290IDIgMCBSCi9JbmZvIDcgMCBSCi9JRCBbPDkzRTNFOTU5Q0U4RUI1RUZFRkRFMjQyNjRCQkIy
NzIxPjw5M0UzRTk1OUNFOEVCNUVGRUZERTI0MjY0QkJCMjcyMT5dCj4+CnN0YXJ0eHJlZgo3NzkK NzIxPjw5M0UzRTk1OUNFOEVCNUVGRUZERTI0MjY0QkJCMjcyMT5dCj4+CnN0YXJ0eHJlZgo3NzkK
JSVFT0YK JSVFT0YK
--===============3363393740883118962== --===============2801843982600110124==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -217,4 +217,4 @@ MDAwMCBuIAowMDAwMDA4NzI4IDAwMDAwIG4gCjAwMDAwMDg3NDcgMDAwMDAgbiAKMDAwMDAwODc2
OSAwMDAwMCBuIAowMDAwMDA4Nzg4IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgMTgKL0luZm8g OSAwMDAwMCBuIAowMDAwMDA4Nzg4IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgMTgKL0luZm8g
MTcgMCBSCi9Sb290IDEgMCBSCj4+CnN0YXJ0eHJlZgo5MjM3CiUlRU9GCg== MTcgMCBSCi9Sb290IDEgMCBSCj4+CnN0YXJ0eHJlZgo5MjM3CiUlRU9GCg==
--===============3363393740883118962==-- --===============2801843982600110124==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============4945638286946888290==" Content-Type: multipart/mixed; boundary="===============1039225266335026552=="
MIME-Version: 1.0 MIME-Version: 1.0
From: foto@example.com From: foto@example.com
To: mcptest@local To: mcptest@local
Subject: Fotos vom Wochenende Subject: Fotos vom Wochenende
Date: Tue, 17 Jun 2026 10:00:00 +0200 Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============4945638286946888290== --===============1039225266335026552==
Content-Type: text/plain; charset="utf-8" Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
QW5iZWkgendlaSBCaWxkZXIu QW5iZWkgendlaSBCaWxkZXIu
--===============4945638286946888290== --===============1039225266335026552==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -194,7 +194,7 @@ AKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigA
ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACi ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACi
iigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/2Q== iigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/2Q==
--===============4945638286946888290== --===============1039225266335026552==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -262,4 +262,4 @@ ABAgwAAQ0O1ULfTCQ1NP1VIA8P+eHTAABAgwAAQIMAAEfKjvgKc8suBU3QcAnFXsgAEgQIABIECA
ASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIAB ASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIAB
IECAASDgfwAcngvShI2jGAAAAABJRU5ErkJggg== IECAASDgfwAcngvShI2jGAAAAABJRU5ErkJggg==
--===============4945638286946888290==-- --===============1039225266335026552==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============8126427565043495650==" Content-Type: multipart/mixed; boundary="===============3148390453173742087=="
MIME-Version: 1.0 MIME-Version: 1.0
From: buero@example.com From: buero@example.com
To: mcptest@local To: mcptest@local
Subject: Quartalsbericht Q2 Subject: Quartalsbericht Q2
Date: Tue, 17 Jun 2026 10:00:00 +0200 Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============8126427565043495650== --===============3148390453173742087==
Content-Type: text/plain; charset="utf-8" Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
QmVyaWNodCAoV29yZCkgdW5kIFphaGxlbiAoRXhjZWwpIGFuYmVpLg== QmVyaWNodCAoV29yZCkgdW5kIFphaGxlbiAoRXhjZWwpIGFuYmVpLg==
--===============8126427565043495650== --===============3148390453173742087==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -663,7 +663,7 @@ AIkVAAASAAAAAAAAAAAAAACAATmBAAB3b3JkL251bWJlcmluZy54bWxQSwECFAMUAAAACABvPtNc
osjWZ70FAACEIAAAFwAAAAAAAAAAAAAAgAHUhAAAZG9jUHJvcHMvdGh1bWJuYWlsLmpwZWdQSwUG osjWZ70FAACEIAAAFwAAAAAAAAAAAAAAgAHUhAAAZG9jUHJvcHMvdGh1bWJuYWlsLmpwZWdQSwUG
AAAAABEAEQBhBAAAxooAAAAA AAAAABEAEQBhBAAAxooAAAAA
--===============8126427565043495650== --===============3148390453173742087==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -756,4 +756,4 @@ AxQAAAAIAG8+01wkHpuirQAAAPgBAAAaAAAAAAAAAAAAAACAAZIOAAB4bC9fcmVscy93b3JrYm9v
ay54bWwucmVsc1BLAQIUAxQAAAAIAG8+01xlkHmSGQEAAM8DAAATAAAAAAAAAAAAAACAAXcPAABb ay54bWwucmVsc1BLAQIUAxQAAAAIAG8+01xlkHmSGQEAAM8DAAATAAAAAAAAAAAAAACAAXcPAABb
Q29udGVudF9UeXBlc10ueG1sUEsFBgAAAAAJAAkAPgIAAMEQAAAAAA== Q29udGVudF9UeXBlc10ueG1sUEsFBgAAAAAJAAkAPgIAAMEQAAAAAA==
--===============8126427565043495650==-- --===============3148390453173742087==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============5981132086698869830==" Content-Type: multipart/mixed; boundary="===============2935331032460717163=="
MIME-Version: 1.0 MIME-Version: 1.0
From: voice@example.com From: voice@example.com
To: mcptest@local To: mcptest@local
Subject: Sprachnachricht Subject: Sprachnachricht
Date: Tue, 17 Jun 2026 10:00:00 +0200 Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============5981132086698869830== --===============2935331032460717163==
Content-Type: text/plain; charset="utf-8" Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
S3VyemUgQXVkaW9uYWNocmljaHQgaW0gQW5oYW5nLg== S3VyemUgQXVkaW9uYWNocmljaHQgaW0gQW5oYW5nLg==
--===============5981132086698869830== --===============2935331032460717163==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -175,4 +175,4 @@ VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+xDE94AJeGVd+akgAAAANIOAAARV
VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ== VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==
--===============5981132086698869830==-- --===============2935331032460717163==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============1387712317359960679==" Content-Type: multipart/mixed; boundary="===============0315165645846539514=="
MIME-Version: 1.0 MIME-Version: 1.0
From: projekt@example.com From: projekt@example.com
To: mcptest@local To: mcptest@local
Subject: Projektdateien Subject: Projektdateien
Date: Tue, 17 Jun 2026 10:00:00 +0200 Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============1387712317359960679== --===============0315165645846539514==
Content-Type: text/plain; charset="utf-8" Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
QXJjaGl2IHVuZCBOb3RpemVuLg== QXJjaGl2IHVuZCBOb3RpemVuLg==
--===============1387712317359960679== --===============0315165645846539514==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -24,7 +24,7 @@ ZGF0YS5jc3ZuYW1lLHdlcnQKQWxwaGEsMQpCZXRhLDIKUEsBAhQDFAAAAAAAbz7TXE28oLEtAAAA
LQAAAAkAAAAAAAAAAAAAALCBAAAAAG5vdGVzLnR4dFBLAQIUAxQAAAAAAG8+01wa5IaIGQAAABkA LQAAAAkAAAAAAAAAAAAAALCBAAAAAG5vdGVzLnR4dFBLAQIUAxQAAAAAAG8+01wa5IaIGQAAABkA
AAAIAAAAAAAAAAAAAACwgVQAAABkYXRhLmNzdlBLBQYAAAAAAgACAG0AAACTAAAAAAA= AAAIAAAAAAAAAAAAAACwgVQAAABkYXRhLmNzdlBLBQYAAAAAAgACAG0AAACTAAAAAAA=
--===============1387712317359960679== --===============0315165645846539514==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -32,7 +32,7 @@ Content-Disposition: attachment; filename="notes.txt"
RWluZmFjaGUgVGV4dGRhdGVpLgpaZWlsZSAyLgpVbWxhdXRlOiBhZW9ldWUK RWluZmFjaGUgVGV4dGRhdGVpLgpaZWlsZSAyLgpVbWxhdXRlOiBhZW9ldWUK
--===============1387712317359960679== --===============0315165645846539514==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -40,4 +40,4 @@ Content-Disposition: attachment; filename="data.csv"
bmFtZSx3ZXJ0CkFscGhhLDEKQmV0YSwyCg== bmFtZSx3ZXJ0CkFscGhhLDEKQmV0YSwyCg==
--===============1387712317359960679==-- --===============0315165645846539514==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============0497362329568289493==" Content-Type: multipart/mixed; boundary="===============4194720031885655663=="
MIME-Version: 1.0 MIME-Version: 1.0
From: media@example.com From: media@example.com
To: mcptest@local To: mcptest@local
Subject: Praesentation + Clip Subject: Praesentation + Clip
Date: Tue, 17 Jun 2026 10:00:00 +0200 Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============0497362329568289493== --===============4194720031885655663==
Content-Type: text/plain; charset="utf-8" Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
Rm9saWVuIHVuZCBlaW4ga3VyemVzIFZpZGVvLg== Rm9saWVuIHVuZCBlaW4ga3VyemVzIFZpZGVvLg==
--===============0497362329568289493== --===============4194720031885655663==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -515,7 +515,7 @@ AAAANgEAACAAAAAAAAAAAAAAAIAB01sAAHBwdC9zbGlkZXMvX3JlbHMvc2xpZGUxLnhtbC5yZWxz
UEsBAhQDFAAAAAgAbz7TXFqgDq2jBQAA4w8AABcAAAAAAAAAAAAAAIAByFwAAGRvY1Byb3BzL3Ro UEsBAhQDFAAAAAgAbz7TXFqgDq2jBQAA4w8AABcAAAAAAAAAAAAAAIAByFwAAGRvY1Byb3BzL3Ro
dW1ibmFpbC5qcGVnUEsFBgAAAAAmACYAowsAAKBiAAAAAA== dW1ibmFpbC5qcGVnUEsFBgAAAAAmACYAowsAAKBiAAAAAA==
--===============0497362329568289493== --===============4194720031885655663==
Content-Type: application/octet-stream Content-Type: application/octet-stream
MIME-Version: 1.0 MIME-Version: 1.0
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
@@ -713,4 +713,4 @@ AX0AAAHtAAABgwAAAukAAADGAAAAUQAAAEYAAAJOAAAAvgAAAGYAAABfAAAAFHN0Y28AAAAAAAAA
AQAAADAAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAA AQAAADAAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAA
AAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYyLjMuMTAw AAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYyLjMuMTAw
--===============0497362329568289493==-- --===============4194720031885655663==--
@@ -0,0 +1,8 @@
From: weird@example.com
To: mcptest@local
Subject: Kaputtes Encoding XUNKNOWNTOKEN
Date: Wed, 10 Jan 2018 12:00:00 +0100
Content-Type: text/plain; charset="x-unknown"
Content-Transfer-Encoding: 8bit
Koerpertext mit kaputtem Charset XUNKNOWNTOKEN und Umlaut ü.
@@ -0,0 +1,7 @@
From: =?invalid-charset?Q?Br=F6tchen?= <bad@example.com>
To: mcptest@local
Subject: =?x-unknown?B?a2FwdXR0?= BADHEADERTOKEN
Date: Fri, 05 Jul 2013 09:00:00 +0200
Content-Type: text/plain; charset=utf-8
Body BADHEADERTOKEN.