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:
+121
@@ -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)
|
||||
Reference in New Issue
Block a user