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