A Model Context Protocol (MCP) server for interacting with TriliumNext via its ETAPI. Enables LLMs to create, read, update, and organize notes โ including embedding images and files directly into note content.
- Features
- Installation
- Configuration โ CLI, env vars, config file
- Available Tools
- Embedding Images and Files
- Multi-tenant HTTP deployment โ run one server for many users
- Architecture
- Quick start (Docker) / (local)
- HTTP endpoints ยท Error responses
- Connecting clients โ Claude Desktop, Claude Code, SDK
- SSRF configuration ยท Reverse-proxy
- Security model ยท Production checklist ยท Troubleshooting
- Debugging with MCP Inspector
- Development โ build, test, docker
- Getting an ETAPI Token
- 35 tools across 8 categories for full note management, search, organization, attachments, revisions, and system operations
- Inline image and file embedding โ attach images and files when creating or updating notes in a single tool call
- Data URL support โ pass image/file data as raw base64 or
data:URLs - Three content update modes โ full replacement, search/replace, and unified diff
- Markdown support โ write in markdown, stored as HTML automatically
- Image-aware content retrieval โ
get_note_contentreturns embedded images as visual content blocks - Support for both STDIO and HTTP (SSE) transports, including multi-tenant SSE mode where each client brings its own Trilium URL + ETAPI token
- Flexible configuration via CLI, environment variables, or config file
- TypeScript with full type safety
git clone https://github.com/perfectra1n/triliumnext-mcp
cd triliumnext-mcp
npm install
npm run buildclaude mcp add trilium node /path/to/triliumnext-mcp/dist/index.js \
--scope user \
-e TRILIUM_TOKEN=<your_etapi_token> \
-e TRILIUM_URL=<your_trilium_url_e.g._https://trilium.example.com/etapi>This adds the server at user scope (available across all repositories) in your ~/.claude.json.
Configuration precedence (highest to lowest):
- CLI arguments
- Environment variables
- Configuration file (
./trilium-mcp.jsonor~/.trilium-mcp.json) - Default values
npm install -g .
triliumnext-mcp --url http://localhost:37740/etapi --token YOUR_TOKENOptions:
-u, --url <url>โ Trilium ETAPI URL (default:http://localhost:37740/etapi)-t, --token <token>โ Trilium ETAPI token (required in single-tenant mode)--transport <type>โ Transport type:stdioorhttp(default:stdio)-p, --port <port>โ HTTP server port when using http transport (default:3000)-h, --helpโ Show help message
Multi-tenant HTTP options (see Multi-tenant HTTP deployment below):
--multi-tenantโ each SSE client supplies its own Trilium URL + token--gateway-auth <mode>โnoneorbearer(default:bearerwhen multi-tenant)--gateway-token <token>โ accepted bearer token (repeatable)--trilium-url-allowlist <hosts>โ comma-separated allowed hostnames for client URLs--allow-private-urlsโ skip the private/loopback IP SSRF block
export TRILIUM_URL=http://localhost:37740/etapi
export TRILIUM_TOKEN=your-etapi-token
export TRILIUM_TRANSPORT=stdio
export TRILIUM_HTTP_PORT=3000
# Multi-tenant (see section below):
export TRILIUM_MULTI_TENANT=true
export TRILIUM_GATEWAY_AUTH=bearer
export TRILIUM_GATEWAY_TOKENS=tok1,tok2
export TRILIUM_URL_ALLOWLIST=notes.example.com,trilium.internal
export TRILIUM_ALLOW_PRIVATE_URLS=falseCreate trilium-mcp.json in the current directory or ~/.trilium-mcp.json:
{
"url": "http://localhost:37740/etapi",
"token": "your-etapi-token",
"transport": "stdio",
"httpPort": 3000
}For multi-tenant HTTP deployments, the same precedence applies (CLI > env > file > default). Multi-tenant keys:
{
"transport": "http",
"httpPort": 3000,
"multiTenant": true,
"gatewayAuth": "bearer",
"gatewayTokens": ["pick-a-long-random-token"],
"urlAllowlist": ["notes.example.com", "trilium.internal"],
"allowPrivateUrls": false
}| Tool | Description |
|---|---|
create_note |
Create a note with title, content, type, and parent. Supports inline image/file embedding. |
get_note |
Get note metadata by ID (title, type, attributes, parent/child relationships) |
get_note_content |
Get note content as HTML or markdown. Automatically includes embedded images as visual content blocks. |
update_note |
Update note metadata (title, type, MIME type) |
update_note_content |
Update note content via full replacement, search/replace, or unified diff. Supports inline image/file embedding in replacement mode. |
append_note_content |
Append content or edit via search/replace or diff. Supports inline image/file embedding in append mode. |
delete_note |
Delete a note and all its branches |
undelete_note |
Restore a previously deleted note |
get_note_attachments |
List all attachments for a note |
get_note_history |
Get recent changes (creations, modifications, deletions) with optional subtree filtering |
| Tool | Description |
|---|---|
search_notes |
Full-text and attribute search with filters, ordering, and limits |
get_note_tree |
Get children of a note for tree navigation |
| Tool | Description |
|---|---|
move_note |
Move a note to a different parent |
clone_note |
Clone a note to appear under multiple parents |
reorder_notes |
Change note positions within a parent |
delete_branch |
Remove a branch without deleting the note |
| Tool | Description |
|---|---|
get_attributes |
Get all attributes (labels/relations) of a note |
get_attribute |
Get a single attribute by ID |
set_attribute |
Add or update an attribute on a note |
delete_attribute |
Remove an attribute from a note |
| Tool | Description |
|---|---|
get_day_note |
Get or create the daily note for a date |
get_inbox_note |
Get the inbox note for quick capture |
| Tool | Description |
|---|---|
create_attachment |
Create a new attachment (image or file) for a note |
get_attachment |
Get attachment metadata by ID |
update_attachment |
Update attachment metadata (role, MIME, title, position) |
delete_attachment |
Delete an attachment |
get_attachment_content |
Get attachment content โ images returned as visual content blocks |
update_attachment_content |
Update attachment content via replacement, search/replace, or diff |
| Tool | Description |
|---|---|
get_note_revisions |
List all revision snapshots for a note |
get_revision |
Get revision metadata by ID |
get_revision_content |
Get the content of a historical revision |
| Tool | Description |
|---|---|
create_revision |
Create a revision snapshot of a note |
create_backup |
Create a full database backup |
export_note |
Export a note subtree as a ZIP file |
search_tools |
Search available tools by keyword or category |
When creating or updating notes, you can embed images and files directly in a single tool call using the images and files parameters.
Pass an images array and reference them in your content with image:0, image:1, etc.:
{
"tool": "create_note",
"arguments": {
"parentNoteId": "root",
"title": "My Note",
"type": "text",
"content": "<p>Here is a photo:</p><img src=\"image:0\">",
"images": [
{
"data": "iVBORw0KGgo...",
"mime": "image/png",
"filename": "photo.png"
}
]
}
}In markdown mode, use :
{
"content": "# My Note\n\n\n\nSome text.",
"format": "markdown",
"images": [{ "data": "iVBORw0KGgo...", "mime": "image/png", "filename": "photo.png" }]
}Images without a matching placeholder are automatically appended at the end of the content.
Pass a files array and reference them with file:0, file:1, etc.:
{
"content": "<p>Download the report: <a href=\"file:0\">Report PDF</a></p>",
"files": [
{
"data": "JVBERi0xLjQ...",
"mime": "application/pdf",
"filename": "report.pdf"
}
]
}Files without a matching placeholder are appended as download links.
The data field accepts both raw base64 and data URLs. When a data URL is provided, the MIME type is automatically extracted (overriding the mime field):
{
"images": [
{
"data": "data:image/png;base64,iVBORw0KGgo...",
"mime": "ignored-when-data-url-is-used",
"filename": "screenshot.png"
}
]
}The update_note_content and append_note_content tools support three modes (images/files only work with mode 1):
- Full replacement (
content) โ replace or append entire content, with optional markdown conversion - Search/replace (
changes) โ array of{old_string, new_string}blocks applied sequentially - Unified diff (
patch) โ a unified diff string applied to existing content
By default the server is single-tenant: TRILIUM_URL and TRILIUM_TOKEN are loaded once at startup and every MCP client that connects talks to the same Trilium instance. That's fine for a personal setup, but if you want to run one MCP server process that serves multiple users, each with their own Trilium and their own ETAPI token, switch it into multi-tenant mode.
With --multi-tenant:
- Each SSE connection MUST supply its own Trilium credentials via HTTP headers, as an atomic pair:
X-Trilium-Urlโ the client's Trilium base URLX-Trilium-Tokenโ the client's ETAPI token
- A per-connection
TriliumClientis created โ connections are isolated; one user's tool calls never hit another's Trilium. - Credentials are verified at connect time by calling
/etapi/app-info(with a 10s timeout). A bad token fails fast with a401on the SSE handshake, not with silent tool-call errors later. - A gateway bearer token is required (
--gateway-auth bearer, enabled by default in multi-tenant mode). Clients authenticate to you with a shared secret you hand out. - Client-supplied URLs are SSRF-checked. By default, hostnames that resolve to private/loopback/link-local IPs (including cloud metadata
169.254.169.254) are rejected. Adjust with--trilium-url-allowlistor--allow-private-urls.
Startup-supplied TRILIUM_URL / TRILIUM_TOKEN are rejected in multi-tenant mode. The server will refuse to start if either is set alongside --multi-tenant. This prevents a subtle token-leak where a client sending only one header would cause the operator's default to be mixed with client-supplied values.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Client A โโโโโโบโ /sse โ โโโโโโโโโโโโโโโโโโ
(Auth: Bearer โ 1. gateway bearer check โโโโบโ Trilium A โ
X-Trilium-*) โ 2. SSRF guard on X-Trilium-Url โ โ (notes-a.tld) โ
โ 3. validate via /etapi/app-info โ โโโโโโโโโโโโโโโโโโ
Client B โโโโโโบโ 4. new TriliumClient (per conn) โ โโโโโโโโโโโโโโโโโโ
โ 5. new MCP Server (per conn) โโโโบโ Trilium B โ
โ โ โ (notes-b.tld) โ
Client N โโโโโโบโ sessions: Map<sessionId, Session> โ โโโโโโโโโโโโโโโโโโ
โ โ ...
โ POST /message?sessionId=<uuid> โ
โ routes to the right session โ
โ โ
โ GET /health (no auth) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Each SSE connection owns an independent Server + TriliumClient. Tool handlers close over the client, so tenant isolation is a property of the code, not something to enforce per-request.
export MCP_GATEWAY_TOKEN=$(openssl rand -hex 32)
docker compose -f docker-compose.multi-tenant.yml up -dDistribute MCP_GATEWAY_TOKEN to authorized clients. Put a TLS-terminating reverse proxy (nginx, Caddy, Traefik) in front of this container โ bearer tokens in plaintext HTTP are unsafe.
npm run build
node dist/index.js \
--transport http \
--port 3000 \
--multi-tenant \
--gateway-token "$(openssl rand -hex 32)"| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/health |
none | Liveness probe โ returns {"status":"ok"}. |
GET |
/sse |
gateway + per-connection | Open an SSE stream. Server replies with an endpoint event containing /message?sessionId=<uuid>. |
POST |
/message |
implicit via sessionId |
Client sends JSON-RPC messages here. Content-Type: application/json, up to 1 MB. |
Any MCP client that can attach custom HTTP headers to an SSE connection will work.
Smoke test with curl:
curl -N \
-H "Authorization: Bearer $MCP_GATEWAY_TOKEN" \
-H "X-Trilium-Url: https://notes.example.com" \
-H "X-Trilium-Token: $YOUR_ETAPI_TOKEN" \
http://mcp-server.example.com:3000/sseOn success you'll see the endpoint SSE event, followed by message events as your client POSTs to /message.
Claude Desktop via mcp-remote:
Claude Desktop speaks stdio, so bridge it through mcp-remote which can carry custom headers to a remote SSE server. Add to claude_desktop_config.json:
{
"mcpServers": {
"trilium": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.example.com/sse",
"--header", "Authorization: Bearer YOUR_GATEWAY_TOKEN",
"--header", "X-Trilium-Url: https://notes.example.com",
"--header", "X-Trilium-Token: YOUR_ETAPI_TOKEN"
]
}
}
}Claude Code (native SSE):
claude mcp add trilium --scope user \
--transport sse https://mcp.example.com/sse \
--header "Authorization: Bearer YOUR_GATEWAY_TOKEN" \
--header "X-Trilium-Url: https://notes.example.com" \
--header "X-Trilium-Token: YOUR_ETAPI_TOKEN"TypeScript SDK:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
const transport = new SSEClientTransport(new URL('https://mcp.example.com/sse'), {
requestInit: {
headers: {
Authorization: `Bearer ${GATEWAY_TOKEN}`,
'X-Trilium-Url': 'https://notes.example.com',
'X-Trilium-Token': ETAPI_TOKEN,
},
},
});
const client = new Client({ name: 'my-app', version: '1.0.0' });
await client.connect(transport);| Flag | Behavior |
|---|---|
| (default) | Reject any X-Trilium-Url whose hostname resolves to a private / loopback / link-local / CGNAT / multicast address. |
--trilium-url-allowlist host1,host2 |
Only hostnames matching the list (exact or suffix โ example.com matches a.example.com) are accepted. Takes precedence over the private-IP block. |
--allow-private-urls |
Disable the private-IP block entirely. Use only on trusted/homelab networks. |
All errors are application/json with an error string. Common responses on GET /sse:
| Status | error value |
Meaning |
|---|---|---|
401 |
unauthorized |
Missing or wrong Authorization: Bearer. |
401 |
missing_trilium_credentials |
X-Trilium-Url and X-Trilium-Token are required together; one or both missing. |
401 |
trilium_auth_failed |
Trilium rejected the ETAPI token. |
400 |
url_rejected (reason varies) |
Bad scheme, embedded credentials, private IP (no allowlist), or not in allowlist. |
502 |
trilium_unreachable |
Can't reach the Trilium host at all. |
504 |
trilium_validate_timeout |
getAppInfo probe exceeded 10 s (suggests a black-hole or slow host). |
On POST /message:
| Status | error value |
Meaning |
|---|---|---|
400 |
missing_session_id |
No ?sessionId= query parameter. |
404 |
unknown_session |
sessionId doesn't match any live SSE connection (typical after a disconnect / restart). |
413 |
payload_too_large |
Content-Length exceeded 1 MB. |
Caddy โ simplest setup, automatic Let's Encrypt:
mcp.example.com {
# preserve the client's Authorization + X-Trilium-* headers (default behavior)
reverse_proxy 127.0.0.1:3000 {
# SSE needs large/indefinite response buffering disabled
flush_interval -1
}
}
nginx โ explicit SSE tuning:
server {
listen 443 ssl http2;
server_name mcp.example.com;
# ssl_certificate / ssl_certificate_key configured elsewhere
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# SSE essentials
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
}
}Make sure the proxy passes through Authorization, X-Trilium-Url, X-Trilium-Token. Both examples above do by default.
GET /health returns {"status":"ok"} with no auth required. Used by the Docker HEALTHCHECK; also useful for load-balancer probes.
- Gateway auth (who can connect at all) is an operator-issued shared bearer token. Constant-time comparison, tokens stored as SHA-256 hashes at startup.
- Backend auth (which Trilium to talk to) is each client's own ETAPI token. It's only ever used to construct that client's
TriliumClient; it's never logged. - Creds are validated at connect time with a 10-second timeout, so a bad or slow Trilium target fails the SSE handshake instead of hanging the connection.
- No TLS in-process. Use a reverse proxy. The server listens on plain HTTP and expects to run behind one.
- No per-user identity. The gateway token is a capability โ anyone holding it can open a session and provide any Trilium credentials. If you need per-principal identity (OIDC, JWT), handle it at the reverse-proxy layer and pass through.
- TLS terminated by a reverse proxy (Caddy / nginx / Traefik) โ never expose port 3000 directly to the public internet.
- Gateway token generated from a CSPRNG (
openssl rand -hex 32) and rotated on compromise by restarting with a new token. -
--trilium-url-allowlistset to the hostnames your users should legitimately reach, or the default private-IP block left in place. -
/healthexposed internally only (behind the proxy), so external scanners can't fingerprint the service. - Container runs as non-root (the shipped
Dockerfilealready usesUSER node). - Reverse proxy logs scrubbed of
Authorization/X-Trilium-Tokenheaders if you forward request headers to an APM. - Firewall rules restrict ingress to the proxy host(s).
- StreamableHTTP transport (MCP's newer replacement for SSE) โ on the roadmap; the routing layer is structured to allow it alongside
/sse. - Rate limiting โ handle at the reverse-proxy layer for now.
- CORS โ no browser MCP clients today; add if/when they appear.
- Per-principal gateway identity (OIDC, JWT) โ use reverse-proxy auth (mod_auth_openidc, oauth2-proxy) if you need it.
- Per-tenant audit logs / metrics โ the per-connection
Servermakes this straightforward to add but isn't implemented yet.
Connection immediately returns 401 unauthorized. Missing or malformed Authorization: Bearer. Check your client logs โ some MCP clients strip non-standard headers on SSE.
Connection returns 401 trilium_auth_failed. The ETAPI token was rejected by Trilium. Test it directly: curl -H "Authorization: $TOKEN" https://trilium.example.com/etapi/app-info.
Connection returns 400 url_rejected with reason=private_address. You're pointing at a private/loopback IP (common in homelabs). Either add the hostname to --trilium-url-allowlist or pass --allow-private-urls.
Connection returns 504 trilium_validate_timeout. getAppInfo didn't respond within 10 seconds. Usually a DNS black hole, a firewall dropping packets, or Trilium is actually down.
Connection succeeds but tool calls hang. Reverse proxy is buffering SSE. Verify proxy_buffering off (nginx) / flush_interval -1 (Caddy).
/health returns 200 but clients get 502/504 from the proxy. Proxy can reach the MCP server, but the server can't reach Trilium from its own network namespace (e.g., Docker bridge vs. host). Check docker exec triliumnext-mcp wget -qO- http://trilium:8080/etapi/app-info.
MCP Inspector provides a web UI for testing tools interactively:
TRILIUM_URL=http://localhost:37740/etapi TRILIUM_TOKEN=your-token npm run inspectorOpens at http://localhost:6274 where you can browse tools, execute calls, and inspect responses.
- Node.js 20+
- npm
- Docker (for integration tests)
npm install # Install dependencies
npm run build # Build TypeScript
npm test # Run unit tests
npm run test:integration # Run integration tests (starts Trilium in Docker)
npm run lint # Run linter
npm run format # Format codeStart Trilium and the MCP server:
TRILIUM_TOKEN=your-token docker compose up -dBuild the Docker image:
docker build -t triliumnext-mcp .- Open TriliumNext in your browser
- Go to Options (gear icon) โ ETAPI
- Create a new ETAPI token
- Copy the token and use it in your configuration
MIT
