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
+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):