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:
co-authored by
Claude Opus 4.8
parent
936ebc2f56
commit
c9ccb4392b
@@ -7,3 +7,4 @@ config.json
|
|||||||
*.before-*
|
*.before-*
|
||||||
.token_audit.log
|
.token_audit.log
|
||||||
*.bak-*
|
*.bak-*
|
||||||
|
*.bak.*
|
||||||
|
|||||||
+31
-7
@@ -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("<","<").replace(">",">").replace("&","&")
|
raw = m.group(1).replace("<","<").replace(">",">").replace("&","&")
|
||||||
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
@@ -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("<","<").replace(">",">").replace("&","&")
|
raw = m.group(1).replace("<","<").replace(">",">").replace("&","&")
|
||||||
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
@@ -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()
|
||||||
|
|||||||
+66
-16
@@ -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):
|
||||||
subj = _decode_hdr(msg.get("Subject", ""))
|
try:
|
||||||
frm = _decode_hdr(msg.get("From", ""))
|
subj = _decode_hdr(msg.get("Subject", ""))
|
||||||
to = _decode_hdr(msg.get("To", ""))
|
frm = _decode_hdr(msg.get("From", ""))
|
||||||
date_str = msg.get("Date", "")
|
to = _decode_hdr(msg.get("To", ""))
|
||||||
if query_lower not in f"{subj} {frm} {to}".lower():
|
date_str = msg.get("Date", "")
|
||||||
body = _get_body(msg)
|
if query_lower not in f"{subj} {frm} {to}".lower():
|
||||||
if query_lower not in body.lower():
|
if query_lower not in _get_body(msg).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:
|
||||||
_trim_memory()
|
continue
|
||||||
return "\n\n".join(results)
|
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 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()
|
@mcp.tool()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)")
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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]}"
|
||||||
Vendored
+27
@@ -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"):
|
||||||
|
|||||||
+9
@@ -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
|
||||||
+5
-5
@@ -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==--
|
||||||
+5
-5
@@ -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==--
|
||||||
+5
-5
@@ -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==--
|
||||||
+4
-4
@@ -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==--
|
||||||
+6
-6
@@ -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==--
|
||||||
+5
-5
@@ -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==--
|
||||||
+8
@@ -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 ü.
|
||||||
+7
@@ -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.
|
||||||
Reference in New Issue
Block a user