Files
janssen/TrilliumMCP/TRASH/mcp_trilium_v1.0.py
T
2026-06-11 09:34:01 +02:00

464 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: <token>.
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 = <br>).
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("<p>" + html.escape(para).replace("\n", "<br>") + "</p>")
return "".join(parts) or "<p></p>"
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"<binární obsah typu {meta.get('type')}/{meta.get('mime')}; použij stažení v UI>"
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()