Files
mcp-home/notes/server.py
T
Stefan LohmaierandClaude Opus 4.8 7c01e7be53 harden(notes): note_id/resource_id auf 32-Hex validieren (Security-Audit)
Audit-Fund: note_id/resource_id gingen ungeprueft in die Joplin-API-URL ->
moegliche Query-/Path-Injection (auf das eigene Konto beschraenkt, aber unschoen).
Fix: _valid_id() (^[0-9a-f]{32}$) als Guard in read_note, list_note_resources,
read_resource, update_note, delete_note. create_note/list/search brauchen keine ID.
Notes-Tests 11/11 gruen; ungueltige ID wird mit klarer Meldung abgelehnt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhHNJKk3RsHgUsJ27WQSom
2026-07-22 12:31:02 +02:00

317 lines
12 KiB
Python

"""MCP Notes Server — reads/writes Joplin notes via Joplin CLI Data API."""
import os, sys, contextlib, base64
from typing import Annotated
import httpx
from pydantic import Field
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents
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, load_config
from pdfutil import pdf_to_content
from officeutil import office_to_content, is_office
_cfg = load_config()
DATA_API = _cfg["joplin_data_api"] # {user: {url, token}}
mcp = FastMCP("Notes", stateless_http=True,
transport_security={"enable_dns_rebinding_protection": False})
IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
def _api(user):
return DATA_API.get(user)
def _get(user, path, params=None):
api = _api(user)
if not api:
return None, "Kein Joplin-Zugang fuer diesen User"
p = dict(params or {})
p["token"] = api["token"]
try:
r = httpx.get(f"{api['url']}{path}", params=p, timeout=30)
if r.status_code >= 400:
return None, f"Joplin HTTP {r.status_code}"
return r, None
except Exception as e:
return None, str(e)
def _post(user, path, json_data):
api = _api(user)
if not api:
return None, "Kein Joplin-Zugang"
try:
r = httpx.post(f"{api['url']}{path}?token={api['token']}", json=json_data, timeout=30)
if r.status_code >= 400:
return None, f"Joplin HTTP {r.status_code}"
return r.json(), None
except Exception as e:
return None, str(e)
import re
_JOPLIN_ID = re.compile(r"^[0-9a-f]{32}$")
def _valid_id(x):
"""Joplin item-IDs sind 32 Hex-Zeichen. Verhindert URL/Query-Injection ueber note_id."""
return isinstance(x, str) and bool(_JOPLIN_ID.match(x))
def _put(user, path, json_data):
api = _api(user)
if not api:
return None, "Kein Joplin-Zugang"
try:
r = httpx.put(f"{api['url']}{path}?token={api['token']}", json=json_data, timeout=30)
if r.status_code >= 400:
return None, f"Joplin HTTP {r.status_code}"
return r.json(), None
except Exception as e:
return None, str(e)
def _delete(user, path, params=None):
api = _api(user)
if not api:
return None, "Kein Joplin-Zugang"
p = dict(params or {})
p["token"] = api["token"]
try:
r = httpx.delete(f"{api['url']}{path}", params=p, timeout=30)
if r.status_code >= 400:
return None, f"Joplin HTTP {r.status_code}"
return r, None
except Exception as e:
return None, str(e)
def _all_items(user, path, params=None):
"""Fetch all pages from a Data API list endpoint."""
items = []
page = 1
while True:
p = dict(params or {})
p["page"] = page
r, err = _get(user, path, p)
if err:
return [], err
d = r.json()
items.extend(d.get("items", []))
if not d.get("has_more"):
break
page += 1
if page > 50:
break
return items, None
@mcp.tool()
def list_notebooks() -> str:
"""List all Joplin notebooks (folders). Call this first to see notebook names and IDs."""
user = get_current_user()
if not user: return "Error: not authenticated"
items, err = _all_items(user, "/folders")
if err: return f"Fehler: {err}"
return "\n".join(f"{i['title']} (id: {i['id']})" for i in items) if items else "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")] = 50,
) -> str:
"""List notes, optionally filtered by notebook. Shows title and ID."""
user = get_current_user()
if not user: return "Error: not authenticated"
nb_id = ""
if notebook:
folders, err = _all_items(user, "/folders")
if err: return f"Fehler: {err}"
for f in folders:
if notebook.lower() in f["title"].lower() or notebook == f["id"]:
nb_id = f["id"]
break
if not nb_id:
return f"Notizbuch nicht gefunden: {notebook}"
path = f"/folders/{nb_id}/notes"
else:
path = "/notes"
items, err = _all_items(user, path, {"fields": "id,title", "order_by": "updated_time", "order_dir": "DESC"})
if err: return f"Fehler: {err}"
return "\n".join(f"{i['title']} (id: {i['id']})" for i in items[:limit]) if items else "Keine Notizen"
@mcp.tool()
def search_notes(
query: Annotated[str, Field(description="Search term — matches note titles and content. Example: 'Einkaufsliste', 'Rezept', 'Passwort'")],
) -> str:
"""Search notes by keyword (full text). Returns matching note titles and IDs."""
user = get_current_user()
if not user: return "Error: not authenticated"
items, err = _all_items(user, "/search", {"query": query, "type": "note", "fields": "id,title"})
if err: return f"Fehler: {err}"
return "\n".join(f"{i['title']} (id: {i['id']})" for i in items[:30]) if items else "Keine Treffer"
@mcp.tool()
def read_note(
note_id: Annotated[str, Field(description="Note ID from list_notes or search_notes")],
) -> str:
"""Read a note's full content (Markdown). Resource links appear as :/resourceId — use list_note_resources + read_resource for attachments."""
user = get_current_user()
if not user: return "Error: not authenticated"
if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)"
r, err = _get(user, f"/notes/{note_id}", {"fields": "id,title,body"})
if err: return f"Fehler: {err}"
d = r.json()
return f"# {d.get('title','?')}\n\n{d.get('body','')}"
@mcp.tool()
def list_note_resources(
note_id: Annotated[str, Field(description="Note ID from list_notes or search_notes")],
) -> str:
"""List attachments (images, PDFs, files) embedded in a note. Use read_resource with the resource ID to fetch one."""
user = get_current_user()
if not user: return "Error: not authenticated"
if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)"
items, err = _all_items(user, f"/notes/{note_id}/resources", {"fields": "id,title,mime,size"})
if err: return f"Fehler: {err}"
if not items:
return "Keine Anhaenge"
lines = []
for i in items:
size = i.get("size", 0)
lines.append(f"{i.get('title','?')} ({i.get('mime','?')}, {size:,} bytes) id: {i['id']}")
return "\n".join(lines)
@mcp.tool()
def read_resource(
resource_id: Annotated[str, Field(description="Resource/attachment ID from list_note_resources")],
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Read an attachment. Images shown inline, PDFs and Office documents (docx, xlsx, pptx) as extracted text, text directly, other formats as binary."""
user = get_current_user()
if not user: return [TextContent(type="text", text="Error: not authenticated")]
if not _valid_id(resource_id): return [TextContent(type="text", text="Fehler: ungueltige resource_id (erwartet 32 Hex-Zeichen)")]
# Get metadata
rmeta, err = _get(user, f"/resources/{resource_id}", {"fields": "id,title,mime,size"})
if err: return [TextContent(type="text", text=f"Fehler: {err}")]
meta = rmeta.json()
mime = meta.get("mime", "application/octet-stream")
title = meta.get("title", "attachment")
# Get blob
rfile, err = _get(user, f"/resources/{resource_id}/file")
if err: return [TextContent(type="text", text=f"Fehler: {err}")]
content = rfile.content
if mime in IMAGE_TYPES and len(content) < 10_000_000:
return [ImageContent(type="image", data=base64.b64encode(content).decode(), mimeType=mime)]
if mime.startswith("text/"):
return [TextContent(type="text", text=content.decode("utf-8", errors="replace")[:100000])]
uri = f"joplin://resource/{resource_id}/{title}"
if mime == "application/pdf" or title.lower().endswith(".pdf"):
return pdf_to_content(content, title, mime, uri=uri)
if is_office(mime, title):
return office_to_content(content, title, mime, uri=uri)
return [EmbeddedResource(type="resource", resource=BlobResourceContents(
uri=uri, blob=base64.b64encode(content).decode(), mimeType=mime))]
@mcp.tool()
def create_note(
notebook: Annotated[str, Field(description="Notebook name or ID where the note is created")],
title: Annotated[str, Field(description="Note title")],
body: Annotated[str, Field(description="Note content in Markdown")],
) -> str:
"""Create a new note in the specified notebook."""
user = get_current_user()
if not user: return "Error: not authenticated"
folders, err = _all_items(user, "/folders")
if err: return f"Fehler: {err}"
nb_id = None
for f in folders:
if notebook.lower() in f["title"].lower() or notebook == f["id"]:
nb_id = f["id"]
break
if not nb_id: return f"Notizbuch nicht gefunden: {notebook}"
d, err = _post(user, "/notes", {"title": title, "body": body, "parent_id": nb_id})
if err: return f"Fehler: {err}"
return f"Notiz erstellt: {title} (id: {d.get('id','?')})"
@mcp.tool()
def update_note(
note_id: Annotated[str, Field(description="Note ID from list_notes or search_notes")],
title: Annotated[str, Field(description="New title. Leave empty to keep the current title.")] = "",
body: Annotated[str, Field(description="New content in Markdown, replaces the whole body. Leave empty to keep the current body. Tip: read_note first, modify, then pass the full new text.")] = "",
notebook: Annotated[str, Field(description="Notebook name or ID to move the note to. Leave empty to keep it where it is.")] = "",
) -> str:
"""Update an existing note: change title and/or body, or move it to another notebook. Only the given fields are changed."""
user = get_current_user()
if not user: return "Error: not authenticated"
if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)"
payload = {}
if title: payload["title"] = title
if body: payload["body"] = body
if notebook:
folders, err = _all_items(user, "/folders")
if err: return f"Fehler: {err}"
nb_id = None
for f in folders:
if notebook.lower() in f["title"].lower() or notebook == f["id"]:
nb_id = f["id"]
break
if not nb_id: return f"Notizbuch nicht gefunden: {notebook}"
payload["parent_id"] = nb_id
if not payload:
return "Nichts zu aendern: title, body oder notebook angeben"
d, err = _put(user, f"/notes/{note_id}", payload)
if err: return f"Fehler: {err}"
changed = ", ".join(sorted(payload))
return f"Notiz aktualisiert ({changed}): {d.get('title','?')} (id: {d.get('id', note_id)})"
@mcp.tool()
def delete_note(
note_id: Annotated[str, Field(description="Note ID from list_notes or search_notes")],
permanent: Annotated[bool, Field(description="False (default): move to trash, recoverable in Joplin. True: delete permanently, NOT recoverable.")] = False,
) -> str:
"""Delete a note. By default it goes to Joplin's trash; permanent=True removes it for good."""
user = get_current_user()
if not user: return "Error: not authenticated"
if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)"
r, err = _get(user, f"/notes/{note_id}", {"fields": "id,title"})
if err: return f"Fehler: {err}"
note_title = r.json().get("title", "?")
params = {"permanent": "1"} if permanent else None
_, err = _delete(user, f"/notes/{note_id}", params)
if err: return f"Fehler: {err}"
if permanent:
return f"Notiz ENDGUELTIG geloescht: {note_title}"
return f"Notiz in den Papierkorb verschoben: {note_title} (in Joplin wiederherstellbar)"
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)