diff --git a/.gitignore b/.gitignore
index 1b8becf..3422d4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ config.json
*.before-*
.token_audit.log
*.bak-*
+*.bak.*
diff --git a/calendar/server.py b/calendar/server.py
index b876275..d1c1792 100644
--- a/calendar/server.py
+++ b/calendar/server.py
@@ -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'{filt}'
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("<","<").replace(">",">").replace("&","&")
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):
diff --git a/contacts/server.py b/contacts/server.py
index cc66fd1..00740ea 100644
--- a/contacts/server.py
+++ b/contacts/server.py
@@ -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 = ''
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("<","<").replace(">",">").replace("&","&")
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):
diff --git a/files/server.py b/files/server.py
index 7c08ee8..99c4e0d 100644
--- a/files/server.py
+++ b/files/server.py
@@ -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()
diff --git a/mail/server.py b/mail/server.py
index e164ea8..af42387 100644
--- a/mail/server.py
+++ b/mail/server.py
@@ -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()
diff --git a/tests/MCPTEST.md b/tests/MCPTEST.md
index 7e5bf36..c3ab04d 100644
--- a/tests/MCPTEST.md
+++ b/tests/MCPTEST.md
@@ -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.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..9fba41e
--- /dev/null
+++ b/tests/conftest.py
@@ -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)")
diff --git a/tests/run_full_tests.sh b/tests/run_full_tests.sh
new file mode 100644
index 0000000..f9d8cbe
--- /dev/null
+++ b/tests/run_full_tests.sh
@@ -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
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index c30198a..81b311d 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -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
diff --git a/tests/test_all.py b/tests/test_all.py
index 106c4ec..f6452dc 100644
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -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/)
diff --git a/tests/test_edge.py b/tests/test_edge.py
new file mode 100644
index 0000000..4e988a3
--- /dev/null
+++ b/tests/test_edge.py
@@ -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", "