#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ============================================================================== MCP server: TRILIUM (zápis poznámek do Trilium Notes přes ETAPI) Verze: 1.0 Datum: 2026-06-09 Autor: vladimir.buzalka Účel: Umožnit libovolnému MCP klientovi (Claude Code v jiné session, Claude Chat, Cowork, …) ČÍST a hlavně PSÁT poznámky do Trilia běžícího na https://trilium.buzalka.cz. Slouží k rychlému předávání poznámek/souborů do prostředí, kam se jinak nedá kopírovat (např. JNJ remote PC). Komunikace: Trilium ETAPI (REST), autentizace hlavičkou Authorization: . Používá jen Python stdlib (urllib) — žádné externí HTTP knihovny. Konvence řazení: Výchozí rodič je složka "Claude" (root > Claude), která má nastaveno #sorted=dateCreated + #sortDirection=desc => nové poznámky jdou NAHORU automaticky, není třeba řešit pozice. Bezpečnost zápisu (dle zvyklostí ostatních zdejších MCP serverů): - create_note / append_note ... bez tření (hlavní účel serveru) - set_note_content (přepis) ... vyžaduje confirmed=True - delete_note ... vyžaduje confirmed=True Spuštění: python mcp_trilium_v1.0.py (stdio MCP server) python mcp_trilium_v1.0.py --selftest (rychlý test create/read/append/delete) Registrace v .mcp.json jako "trilium" (viz doprovodné .md). ============================================================================== """ from __future__ import annotations import os import sys import json import html import hashlib import mimetypes import urllib.parse import urllib.request import urllib.error from typing import Optional from mcp.server.fastmcp import FastMCP # --- konfigurace ------------------------------------------------------------ BASE_URL = os.environ.get("TRILIUM_URL", "https://trilium.buzalka.cz").rstrip("/") ETAPI = BASE_URL + "/etapi" # Token lze přepsat proměnnou prostředí TRILIUM_ETAPI_TOKEN; jinak default níže. TOKEN = os.environ.get( "TRILIUM_ETAPI_TOKEN", "WoPH9O8hn2y6_r6pQSjpOVSmuL0os2hIQsLBHDOawebOx8l+MUc8v+GE=", ) # Výchozí složka, kam se zapisuje (root > Claude), newest-first. CLAUDE_NOTE_ID = os.environ.get("TRILIUM_DEFAULT_PARENT", "NeoXOIw0uBK2") HTTP_TIMEOUT = 20 def log(msg: str) -> None: print(msg, file=sys.stderr, flush=True) # --- nízkoúrovňové ETAPI volání --------------------------------------------- def _req(method: str, path: str, *, json_body=None, raw_body: Optional[bytes] = None, raw_ctype: Optional[str] = None, expect_bytes: bool = False): """Jedno ETAPI volání. Vrací dict/list/str (JSON nebo text), nebo bytes (expect_bytes=True). Vyhazuje RuntimeError s tělem chyby při HTTP >= 400.""" body = None ctype = None if json_body is not None: body = json.dumps(json_body, ensure_ascii=False).encode("utf-8") ctype = "application/json; charset=utf-8" elif raw_body is not None: body = raw_body ctype = raw_ctype or "application/octet-stream" req = urllib.request.Request(ETAPI + path, data=body, method=method) req.add_header("Authorization", TOKEN) if ctype: req.add_header("Content-Type", ctype) try: with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: data = resp.read() if expect_bytes: return data text = data.decode("utf-8", errors="replace") if text.strip().startswith(("{", "[")): return json.loads(text) return text except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", errors="replace") raise RuntimeError(f"ETAPI {method} {path} -> HTTP {e.code}: {detail}") from None except urllib.error.URLError as e: raise RuntimeError(f"ETAPI {method} {path} -> spojení selhalo: {e.reason}") from None def _text_to_html(text: str, is_html: bool) -> str: """Plain text -> bezpečné HTML (prázdné řádky = odstavce, \\n =
). Pokud is_html=True, obsah se bere tak, jak je.""" if is_html: return text text = text.replace("\r\n", "\n").replace("\r", "\n") parts = [] for para in text.split("\n\n"): if para.strip() == "": continue parts.append("

" + html.escape(para).replace("\n", "
") + "

") return "".join(parts) or "

" def _put_text_content(note_id: str, html_content: str) -> None: """Uloží HTML obsah textové poznámky (UTF-8, ověřený způsob: text/plain;charset).""" _req("PUT", f"/notes/{note_id}/content", raw_body=html_content.encode("utf-8"), raw_ctype="text/plain; charset=utf-8") def _note_summary(note: dict) -> dict: """Zhuštěné info o poznámce do tool response.""" return { "noteId": note.get("noteId"), "title": note.get("title"), "type": note.get("type"), "mime": note.get("mime"), "parentNoteIds": note.get("parentNoteIds"), "childNoteIds": note.get("childNoteIds"), "dateCreated": note.get("dateCreated"), "dateModified": note.get("dateModified"), "url": f"{BASE_URL}/#root/{note.get('noteId')}", } # --- startup check ---------------------------------------------------------- try: _info = _req("GET", "/app-info") log(f"Trilium OK ({BASE_URL}) — appVersion {_info.get('appVersion')}") except Exception as e: # noqa: BLE001 log(f"Trilium ETAPI nedostupné: {e}") sys.exit(1) # --- MCP -------------------------------------------------------------------- mcp = FastMCP("trilium") @mcp.tool() def ping() -> dict: """Health check Trilium ETAPI. Vrátí verzi aplikace, DB a default složku. Zavolej jako první pro ověření, že je server dostupný a token platí.""" try: info = _req("GET", "/app-info") return { "status": "ok", "base_url": BASE_URL, "appVersion": info.get("appVersion"), "dbVersion": info.get("dbVersion"), "default_parent": CLAUDE_NOTE_ID, "default_parent_url": f"{BASE_URL}/#root/{CLAUDE_NOTE_ID}", } except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def create_note( title: str, content: str = "", parent_note_id: Optional[str] = None, is_html: bool = False, ) -> dict: """Vytvoří NOVOU textovou poznámku. Hlavní nástroj pro předávání poznámek. title: název poznámky (zobrazí se ve stromu) content: text poznámky; prostý text se převede na HTML (prázdný řádek = nový odstavec). Pokud už posíláš HTML, nastav is_html=True. parent_note_id: kam ji zařadit. Default = složka "Claude" (root > Claude), která řadí potomky nejnovější-nahoře automaticky. is_html: True pokud content už JE HTML. Vrací noteId a URL nové poznámky. """ try: parent = parent_note_id or CLAUDE_NOTE_ID html_content = _text_to_html(content, is_html) res = _req("POST", "/create-note", json_body={ "parentNoteId": parent, "title": title, "type": "text", "content": html_content, }) note = res["note"] log(f"create_note: {note['noteId']} '{title}' pod {parent}") return {"status": "ok", **_note_summary(note)} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def append_note(note_id: str, text: str, is_html: bool = False) -> dict: """Připíše text na KONEC existující textové poznámky (nedestruktivní). note_id: ID poznámky, ke které připisujeme text: co připsat; prostý text -> HTML (is_html=True pokud posíláš HTML) """ try: meta = _req("GET", f"/notes/{note_id}") if meta.get("type") != "text": return {"status": "error", "error": f"Poznámka {note_id} není typu 'text' (je '{meta.get('type')}')."} current = _req("GET", f"/notes/{note_id}/content") if not isinstance(current, str): current = json.dumps(current, ensure_ascii=False) addition = _text_to_html(text, is_html) _put_text_content(note_id, (current or "") + addition) log(f"append_note: {note_id} (+{len(text)} znaků)") return {"status": "ok", "noteId": note_id, "url": f"{BASE_URL}/#root/{note_id}"} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def set_note_content(note_id: str, content: str, is_html: bool = False, confirmed: bool = False) -> dict: """PŘEPÍŠE celý obsah textové poznámky (DESTRUKTIVNÍ — smaže stávající text). Vyžaduje confirmed=True. Při confirmed=False jen vrátí náhled: délku stávajícího obsahu a co by se zapsalo — to ukaž uživateli a teprve po schválení zavolej znovu s confirmed=True. """ try: meta = _req("GET", f"/notes/{note_id}") if meta.get("type") != "text": return {"status": "error", "error": f"Poznámka {note_id} není typu 'text' (je '{meta.get('type')}')."} new_html = _text_to_html(content, is_html) if not confirmed: current = _req("GET", f"/notes/{note_id}/content") cur_len = len(current) if isinstance(current, str) else 0 return { "status": "preview", "noteId": note_id, "title": meta.get("title"), "current_content_length": cur_len, "new_content_preview": new_html[:500], "note": "Přepis smaže stávající obsah. Ukaž uživateli a zavolej znovu s confirmed=True.", } _put_text_content(note_id, new_html) log(f"set_note_content: {note_id} přepsáno") return {"status": "ok", "noteId": note_id, "url": f"{BASE_URL}/#root/{note_id}"} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def create_folder(title: str, parent_note_id: str = "root", newest_first: bool = True, place_on_top: bool = False) -> dict: """Vytvoří podsložku (poznámku, pod kterou se vnořují další). title: název složky parent_note_id: rodič (default "root") newest_first: nastaví #sorted=dateCreated + #sortDirection=desc, takže potomci se řadí nejnovější-nahoře (jako složka Claude) place_on_top: posune složku na úplný začátek u svého rodiče (pozice 0) """ try: res = _req("POST", "/create-note", json_body={ "parentNoteId": parent_note_id, "title": title, "type": "text", "content": "", }) note = res["note"] nid = note["noteId"] if newest_first: _req("POST", "/attributes", json_body={ "noteId": nid, "type": "label", "name": "sorted", "value": "dateCreated", "isInheritable": False}) _req("POST", "/attributes", json_body={ "noteId": nid, "type": "label", "name": "sortDirection", "value": "desc", "isInheritable": False}) if place_on_top: branch_id = res["branch"]["branchId"] _req("PATCH", f"/branches/{branch_id}", json_body={"notePosition": 0}) log(f"create_folder: {nid} '{title}' pod {parent_note_id}") return {"status": "ok", "newest_first": newest_first, "place_on_top": place_on_top, **_note_summary(note)} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def upload_file(file_path: str, parent_note_id: Optional[str] = None, title: Optional[str] = None) -> dict: """Nahraje LOKÁLNÍ soubor (z disku stroje, kde běží tento MCP) jako samostatnou poznámku typu 'file'. Po nahrání ověří integritu přes SHA-256. file_path: absolutní cesta k souboru na tomto stroji parent_note_id: kam (default složka Claude) title: název poznámky (default = jméno souboru) """ try: if not os.path.isfile(file_path): return {"status": "error", "error": f"Soubor neexistuje: {file_path}"} parent = parent_note_id or CLAUDE_NOTE_ID fname = os.path.basename(file_path) with open(file_path, "rb") as fh: blob = fh.read() mime = mimetypes.guess_type(fname)[0] or "application/octet-stream" sha_local = hashlib.sha256(blob).hexdigest() res = _req("POST", "/create-note", json_body={ "parentNoteId": parent, "title": title or fname, "type": "file", "mime": mime, "content": ""}) nid = res["note"]["noteId"] _req("PUT", f"/notes/{nid}/content", raw_body=blob, raw_ctype="application/octet-stream") _req("POST", "/attributes", json_body={ "noteId": nid, "type": "label", "name": "originalFileName", "value": fname, "isInheritable": False}) back = _req("GET", f"/notes/{nid}/content", expect_bytes=True) sha_remote = hashlib.sha256(back).hexdigest() log(f"upload_file: {nid} '{fname}' {len(blob)}B match={sha_local == sha_remote}") return { "status": "ok", "noteId": nid, "title": title or fname, "mime": mime, "size_bytes": len(blob), "sha256": sha_local, "integrity_ok": sha_local == sha_remote, "url": f"{BASE_URL}/#root/{nid}", } except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def read_note(note_id: str) -> dict: """Přečte obsah poznámky (text/HTML) + základní metadata.""" try: meta = _req("GET", f"/notes/{note_id}") result = {"status": "ok", **_note_summary(meta)} if meta.get("type") in ("text", "code", "mermaid"): content = _req("GET", f"/notes/{note_id}/content") result["content"] = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) else: result["content"] = f"" return result except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def list_children(note_id: Optional[str] = None) -> dict: """Vypíše přímé potomky poznámky (default = složka Claude) v pořadí stromu.""" try: nid = note_id or CLAUDE_NOTE_ID meta = _req("GET", f"/notes/{nid}") children = [] for child_branch in meta.get("childBranchIds", []): br = _req("GET", f"/branches/{child_branch}") ch = _req("GET", f"/notes/{br['noteId']}") children.append({ "noteId": ch.get("noteId"), "title": ch.get("title"), "type": ch.get("type"), "notePosition": br.get("notePosition"), "dateCreated": ch.get("dateCreated"), }) return {"status": "ok", "parent": nid, "parent_title": meta.get("title"), "count": len(children), "children": children} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def search_notes(query: str, limit: int = 20, ancestor_note_id: Optional[str] = None) -> dict: """Fulltextové / atributové vyhledávání poznámek (Trilium search syntax). query: např. 'protokol', '#book', 'note.title *=* report' limit: max počet výsledků (default 20) ancestor_note_id: omez hledání na podstrom (např. složka Claude) """ try: params = {"search": query, "limit": str(max(1, min(limit, 200)))} if ancestor_note_id: params["ancestorNoteId"] = ancestor_note_id qs = urllib.parse.urlencode(params) res = _req("GET", f"/notes?{qs}") results = res.get("results", []) if isinstance(res, dict) else [] out = [{ "noteId": n.get("noteId"), "title": n.get("title"), "type": n.get("type"), "dateModified": n.get("dateModified"), "url": f"{BASE_URL}/#root/{n.get('noteId')}", } for n in results] return {"status": "ok", "query": query, "count": len(out), "results": out} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} @mcp.tool() def delete_note(note_id: str, confirmed: bool = False) -> dict: """SMAŽE poznámku (a její potomky) — DESTRUKTIVNÍ. Vyžaduje confirmed=True. Při confirmed=False vrátí náhled (název + počet potomků), který ukaž uživateli; teprve po schválení zavolej s confirmed=True. """ try: meta = _req("GET", f"/notes/{note_id}") if not confirmed: return { "status": "preview", "noteId": note_id, "title": meta.get("title"), "type": meta.get("type"), "child_count": len(meta.get("childNoteIds", [])), "note": "Smazání je nevratné a zahrne i potomky. Ukaž uživateli a zavolej znovu s confirmed=True.", } _req("DELETE", f"/notes/{note_id}") log(f"delete_note: {note_id} '{meta.get('title')}' smazáno") return {"status": "ok", "deleted": note_id, "title": meta.get("title")} except Exception as e: # noqa: BLE001 return {"status": "error", "error": str(e)} # --- selftest (mimo MCP) ---------------------------------------------------- def selftest() -> None: try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass print("== Trilium MCP selftest ==") print("ping:", json.dumps(ping(), ensure_ascii=False)) c = create_note("MCP selftest – ěščřž", "První řádek.\n\nDruhý odstavec — diakritika.") print("create:", json.dumps(c, ensure_ascii=False)) nid = c.get("noteId") if nid: print("append:", json.dumps(append_note(nid, "Připsaný řádek."), ensure_ascii=False)) r = read_note(nid) print("read.content:", r.get("content")) print("delete:", json.dumps(delete_note(nid, confirmed=True), ensure_ascii=False)) if __name__ == "__main__": if "--selftest" in sys.argv: selftest() else: log("MCP trilium server started (FastMCP)") mcp.run()