Initial commit: 5 MCP servers for Mail, Calendar, Contacts, Files, Notes

Self-hosted MCP servers with OAuth client_credentials auth.
Each server connects to a different backend:
- Mail: reads Maildir IMAP backups
- Calendar/Tasks: CalDAV against Radicale
- Contacts: CardDAV against Radicale
- Files: WebDAV against oCIS
- Notes: Joplin REST API

Credentials externalized to config.json (not in repo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Lohmaier
2026-06-12 06:22:42 +02:00
commit fb642e47c8
11 changed files with 1199 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
"""MCP Notes Server — reads and writes notes via Joplin API."""
import os, sys, contextlib
from typing import Annotated
import httpx
from pydantic import Field
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.routing import Mount
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from common import get_current_user, OAUTH_ROUTES, BearerAuthMiddleware
from common import load_config as _lc
_cfg = _lc()
JOPLIN = _cfg['joplin_url']
JOPLIN_TOKENS = _cfg['joplin_tokens']
mcp = FastMCP("Notes", stateless_http=True,
transport_security={"enable_dns_rebinding_protection": False})
def _api(user, endpoint, method="GET", json_data=None):
token = JOPLIN_TOKENS.get(user)
if not token: return None, "Kein Joplin API-Token konfiguriert. Bitte in Joplin-App generieren."
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Origin": "https://notes.home.slohmaier.de"}
try:
r = getattr(httpx, method.lower())(f"{JOPLIN}{endpoint}", headers=headers, json=json_data, timeout=15)
if r.status_code >= 400: return None, f"Joplin-Fehler: HTTP {r.status_code}"
return (r.json() if r.text else {}), None
except Exception as e: return None, str(e)
@mcp.tool()
def list_notebooks() -> str:
"""List all Joplin notebooks (folders). Call this first to see available notebooks."""
user = get_current_user()
if not user: return "Error: not authenticated"
data, err = _api(user, "/api/folders")
if err: return f"Fehler: {err}"
return "\n".join(f"{nb['title']} (ID: {nb['id']})" for nb in data.get("items", [])) or "Keine Notizbuecher"
@mcp.tool()
def list_notes(
notebook: Annotated[str, Field(description="Notebook name or ID from list_notebooks. Leave empty for all notes.")] = "",
limit: Annotated[int, Field(description="Maximum number of notes to return")] = 50,
) -> str:
"""List notes, optionally filtered by notebook. Shows title and ID. Use read_note with the ID to get content."""
user = get_current_user()
if not user: return "Error: not authenticated"
if notebook:
data, err = _api(user, "/api/folders")
if err: return f"Fehler: {err}"
nb_id = next((nb["id"] for nb in data.get("items", []) if notebook.lower() in nb.get("title","").lower() or notebook == nb.get("id")), None)
if not nb_id: return f"Notizbuch '{notebook}' nicht gefunden"
data, err = _api(user, f"/api/folders/{nb_id}/notes?limit={limit}")
else:
data, err = _api(user, f"/api/notes?limit={limit}&order_by=updated_time&order_dir=DESC")
if err: return f"Fehler: {err}"
return "\n".join(f"{n['title']} (ID: {n['id']})" for n in data.get("items", [])) or "Keine Notizen"
@mcp.tool()
def read_note(
note_id: Annotated[str, Field(description="Note ID from list_notes or search_notes results")],
) -> str:
"""Read the full content of a note (Markdown format)."""
user = get_current_user()
if not user: return "Error: not authenticated"
data, err = _api(user, f"/api/notes/{note_id}?fields=id,title,body")
if err: return f"Fehler: {err}"
return f"# {data.get('title','')}\n\n{data.get('body','')}"
@mcp.tool()
def search_notes(
query: Annotated[str, Field(description="Search term — matches note titles and content. Example: 'Einkaufsliste', 'Passwort', 'Rezept'")],
) -> str:
"""Search notes by keyword. Returns matching note titles and IDs."""
user = get_current_user()
if not user: return "Error: not authenticated"
data, err = _api(user, f"/api/search?query={query}&limit=20")
if err: return f"Fehler: {err}"
return "\n".join(f"{n['title']} (ID: {n['id']})" for n in data.get("items", [])) or "Keine Treffer"
@mcp.tool()
def create_note(
notebook: Annotated[str, Field(description="Notebook name or ID where the note should be created")],
title: Annotated[str, Field(description="Note title")],
body: Annotated[str, Field(description="Note content in Markdown format")],
) -> str:
"""Create a new note in the specified notebook."""
user = get_current_user()
if not user: return "Error: not authenticated"
folders, err = _api(user, "/api/folders")
if err: return f"Fehler: {err}"
nb_id = next((nb["id"] for nb in folders.get("items", []) if notebook.lower() in nb.get("title","").lower() or notebook == nb.get("id")), None)
if not nb_id: return f"Notizbuch '{notebook}' nicht gefunden"
data, err = _api(user, "/api/notes", "POST", {"title": title, "body": body, "parent_id": nb_id})
if err: return f"Fehler: {err}"
return f"Notiz erstellt: {title}"
def create_app():
from contextlib import asynccontextmanager
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def lifespan(app):
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
yield
routes = list(OAUTH_ROUTES) + [Mount("/", app=mcp_app)]
app = Starlette(routes=routes, lifespan=lifespan)
app.add_middleware(BearerAuthMiddleware)
return app
if __name__ == "__main__":
import uvicorn
uvicorn.run(create_app(), host="127.0.0.1", port=5104)