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-*
.token_audit.log
*.bak-*
*.bak.*
+31 -7
View File
@@ -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'<?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
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[^>]*>(.*?)</(?: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;","&")
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):
+16 -3
View File
@@ -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 = '<?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)
contacts = []
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;","&")
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):
+20 -3
View File
@@ -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()
+66 -16
View File
@@ -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()
+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.
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.
+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
# 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
+46
View File
@@ -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/)
+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("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
for r,d,f in os.walk(BASE):
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
From: billing@example.com
To: mcptest@local
Subject: Ihre Rechnung Juni 2026
Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============3363393740883118962==
--===============2801843982600110124==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -13,7 +13,7 @@ Content-Transfer-Encoding: base64
SW0gQW5oYW5nIGRpZSBSZWNobnVuZyAoVGV4dC1QREYpIHVuZCBkZXIgU2NhbiAoQmlsZC1QREYp
Lg==
--===============3363393740883118962==
--===============2801843982600110124==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -40,7 +40,7 @@ b290IDIgMCBSCi9JbmZvIDcgMCBSCi9JRCBbPDkzRTNFOTU5Q0U4RUI1RUZFRkRFMjQyNjRCQkIy
NzIxPjw5M0UzRTk1OUNFOEVCNUVGRUZERTI0MjY0QkJCMjcyMT5dCj4+CnN0YXJ0eHJlZgo3NzkK
JSVFT0YK
--===============3363393740883118962==
--===============2801843982600110124==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -217,4 +217,4 @@ MDAwMCBuIAowMDAwMDA4NzI4IDAwMDAwIG4gCjAwMDAwMDg3NDcgMDAwMDAgbiAKMDAwMDAwODc2
OSAwMDAwMCBuIAowMDAwMDA4Nzg4IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgMTgKL0luZm8g
MTcgMCBSCi9Sb290IDEgMCBSCj4+CnN0YXJ0eHJlZgo5MjM3CiUlRU9GCg==
--===============3363393740883118962==--
--===============2801843982600110124==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============4945638286946888290=="
Content-Type: multipart/mixed; boundary="===============1039225266335026552=="
MIME-Version: 1.0
From: foto@example.com
To: mcptest@local
Subject: Fotos vom Wochenende
Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============4945638286946888290==
--===============1039225266335026552==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
QW5iZWkgendlaSBCaWxkZXIu
--===============4945638286946888290==
--===============1039225266335026552==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -194,7 +194,7 @@ AKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigA
ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACi
iigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/2Q==
--===============4945638286946888290==
--===============1039225266335026552==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -262,4 +262,4 @@ ABAgwAAQ0O1ULfTCQ1NP1VIA8P+eHTAABAgwAAQIMAAEfKjvgKc8suBU3QcAnFXsgAEgQIABIECA
ASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIABIECAASBAgAEgQIAB
IECAASDgfwAcngvShI2jGAAAAABJRU5ErkJggg==
--===============4945638286946888290==--
--===============1039225266335026552==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============8126427565043495650=="
Content-Type: multipart/mixed; boundary="===============3148390453173742087=="
MIME-Version: 1.0
From: buero@example.com
To: mcptest@local
Subject: Quartalsbericht Q2
Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============8126427565043495650==
--===============3148390453173742087==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
QmVyaWNodCAoV29yZCkgdW5kIFphaGxlbiAoRXhjZWwpIGFuYmVpLg==
--===============8126427565043495650==
--===============3148390453173742087==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -663,7 +663,7 @@ AIkVAAASAAAAAAAAAAAAAACAATmBAAB3b3JkL251bWJlcmluZy54bWxQSwECFAMUAAAACABvPtNc
osjWZ70FAACEIAAAFwAAAAAAAAAAAAAAgAHUhAAAZG9jUHJvcHMvdGh1bWJuYWlsLmpwZWdQSwUG
AAAAABEAEQBhBAAAxooAAAAA
--===============8126427565043495650==
--===============3148390453173742087==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -756,4 +756,4 @@ AxQAAAAIAG8+01wkHpuirQAAAPgBAAAaAAAAAAAAAAAAAACAAZIOAAB4bC9fcmVscy93b3JrYm9v
ay54bWwucmVsc1BLAQIUAxQAAAAIAG8+01xlkHmSGQEAAM8DAAATAAAAAAAAAAAAAACAAXcPAABb
Q29udGVudF9UeXBlc10ueG1sUEsFBgAAAAAJAAkAPgIAAMEQAAAAAA==
--===============8126427565043495650==--
--===============3148390453173742087==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============5981132086698869830=="
Content-Type: multipart/mixed; boundary="===============2935331032460717163=="
MIME-Version: 1.0
From: voice@example.com
To: mcptest@local
Subject: Sprachnachricht
Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============5981132086698869830==
--===============2935331032460717163==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
S3VyemUgQXVkaW9uYWNocmljaHQgaW0gQW5oYW5nLg==
--===============5981132086698869830==
--===============2935331032460717163==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -175,4 +175,4 @@ VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+xDE94AJeGVd+akgAAAANIOAAARV
VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==
--===============5981132086698869830==--
--===============2935331032460717163==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============1387712317359960679=="
Content-Type: multipart/mixed; boundary="===============0315165645846539514=="
MIME-Version: 1.0
From: projekt@example.com
To: mcptest@local
Subject: Projektdateien
Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============1387712317359960679==
--===============0315165645846539514==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
QXJjaGl2IHVuZCBOb3RpemVuLg==
--===============1387712317359960679==
--===============0315165645846539514==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -24,7 +24,7 @@ ZGF0YS5jc3ZuYW1lLHdlcnQKQWxwaGEsMQpCZXRhLDIKUEsBAhQDFAAAAAAAbz7TXE28oLEtAAAA
LQAAAAkAAAAAAAAAAAAAALCBAAAAAG5vdGVzLnR4dFBLAQIUAxQAAAAAAG8+01wa5IaIGQAAABkA
AAAIAAAAAAAAAAAAAACwgVQAAABkYXRhLmNzdlBLBQYAAAAAAgACAG0AAACTAAAAAAA=
--===============1387712317359960679==
--===============0315165645846539514==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -32,7 +32,7 @@ Content-Disposition: attachment; filename="notes.txt"
RWluZmFjaGUgVGV4dGRhdGVpLgpaZWlsZSAyLgpVbWxhdXRlOiBhZW9ldWUK
--===============1387712317359960679==
--===============0315165645846539514==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -40,4 +40,4 @@ Content-Disposition: attachment; filename="data.csv"
bmFtZSx3ZXJ0CkFscGhhLDEKQmV0YSwyCg==
--===============1387712317359960679==--
--===============0315165645846539514==--
@@ -1,18 +1,18 @@
Content-Type: multipart/mixed; boundary="===============0497362329568289493=="
Content-Type: multipart/mixed; boundary="===============4194720031885655663=="
MIME-Version: 1.0
From: media@example.com
To: mcptest@local
Subject: Praesentation + Clip
Date: Tue, 17 Jun 2026 10:00:00 +0200
--===============0497362329568289493==
--===============4194720031885655663==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Rm9saWVuIHVuZCBlaW4ga3VyemVzIFZpZGVvLg==
--===============0497362329568289493==
--===============4194720031885655663==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -515,7 +515,7 @@ AAAANgEAACAAAAAAAAAAAAAAAIAB01sAAHBwdC9zbGlkZXMvX3JlbHMvc2xpZGUxLnhtbC5yZWxz
UEsBAhQDFAAAAAgAbz7TXFqgDq2jBQAA4w8AABcAAAAAAAAAAAAAAIAByFwAAGRvY1Byb3BzL3Ro
dW1ibmFpbC5qcGVnUEsFBgAAAAAmACYAowsAAKBiAAAAAA==
--===============0497362329568289493==
--===============4194720031885655663==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
@@ -713,4 +713,4 @@ AX0AAAHtAAABgwAAAukAAADGAAAAUQAAAEYAAAJOAAAAvgAAAGYAAABfAAAAFHN0Y28AAAAAAAAA
AQAAADAAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAA
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.