#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ============================================================================== MCP server: TRILIUM (zápis poznámek do Trilium Notes přes ETAPI) Verze: 1.1 Datum: 2026-06-11 Autor: vladimir.buzalka Změny v1.1: + tool create_summary — ukládá summary diskuze do složky ClaudeSummaries (root > ClaudeSummaries, hned pod složkou Claude). Vynucuje pravidla: 1. první řádek poznámky = "DATUM ČAS — kdo summary vytvořil" (např. "2026-06-11 09:30 — ClaudeCode z počítače Z230") 2. summary musí být dost podrobné, aby šlo kdykoliv navázat na práci (kontext, co se udělalo, jak, výsledky, co zbývá, cesty k souborům) Úč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.1.py (stdio MCP server) python mcp_trilium_v1.1.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 datetime 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") # Složka pro summary diskuzí (root > ClaudeSummaries, hned pod Claude), newest-first. SUMMARY_NOTE_ID = os.environ.get("TRILIUM_SUMMARY_PARENT", "gwBGibRzpN8d") 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 create_summary(title: str, content: str, creator: Optional[str] = None) -> dict: """Uloží SUMMARY diskuze/práce do složky ClaudeSummaries (root > ClaudeSummaries). PRAVIDLA PRO SUMMARY (dodržuj při sestavování obsahu): 1. První řádek poznámky doplní tento tool automaticky ve tvaru "YYYY-MM-DD HH:MM — " (kdo a kdy summary vytvořil). NEZAČÍNEJ proto content vlastní hlavičkou s datem. 2. Summary piš tak podrobně, aby šlo KDYKOLIV (i za půl roku) navázat na práci bez znalosti původní konverzace. Vždy uveď: - kontext/cíl (co se řešilo a proč), - co se udělalo a JAK (postupy, příkazy, triky, řešené problémy), - výsledky (čísla, stavy, co je ověřeno), - co zbývá / další kroky, - cesty k souborům, skriptům, ID záznamů, odkazy. title: název poznámky — datum + téma (např. "2026-06-11 Úklid disku C"). content: text summary (prostý text; prázdný řádek = nový odstavec). creator: kdo summary vytváří; default "ClaudeCode z počítače ". """ try: who = creator or f"ClaudeCode z počítače {os.environ.get('COMPUTERNAME', 'neznámý')}" stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") header = f"{stamp} — {who}" html_content = _text_to_html(header + "\n\n" + content, is_html=False) res = _req("POST", "/create-note", json_body={ "parentNoteId": SUMMARY_NOTE_ID, "title": title, "type": "text", "content": html_content, }) note = res["note"] log(f"create_summary: {note['noteId']} '{title}' ({who})") return {"status": "ok", "header": header, **_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()