How it works¶
trilium-mcp turns every ETAPI endpoint into an MCP tool and forwards each call to Trilium with an ETAPI token: either the one the client sent, or, with OAuth, the one it minted for that client at login.
flowchart LR
app["App client<br>OAuth login"]
headless["Headless client<br>ETAPI token"]
lan["Client on a trusted LAN<br>ETAPI token"]
proxy["Reverse proxy<br>TLS"]
subgraph host["Docker host"]
mcp["trilium-mcp<br>:8081 /mcp"]
oauth[("mcp-oauth<br>encrypted")]
trilium["Trilium<br>:8080 /etapi"]
data[("trilium-data")]
end
app -- HTTPS --> proxy
headless -- HTTPS --> proxy
lan -- HTTP --> mcp
proxy --> mcp
mcp -- OAuth logins --> oauth
mcp -- ETAPI --> trilium
trilium --> data
classDef accent fill:#98D486,stroke:#2f7a27,color:#10200c,stroke-width:1.5px
class mcp accent
trilium-mcp runs as a container sidecar and talks to Trilium over the internal Docker network, so Trilium's ETAPI is never exposed publicly on its own. Clients reach it through a TLS-terminating reverse proxy or directly over a trusted LAN.
Tools from the OpenAPI spec¶
At startup the server reads the bundled ETAPI OpenAPI spec and generates one tool per endpoint with FastMCP.from_openapi. A few endpoints don't fit the JSON-in, JSON-out shape and are handled specially:
- Text and HTML responses (such as
getNoteContent) keep the generated tool but drop its output schema. - ZIP export (
exportNoteSubtree) is replaced by a tool that unpacks the archive and returns readable text. - Plain-text uploads (
putNoteContentById,putAttachmentContentById) are replaced by tools that send the body with an explicittext/plaincontent type. loginandlogoutare left out: clients already authenticate through the header, and logout would invalidate their own credential.
ETAPI token¶
The client's token passes through unchanged. trilium-mcp stores nothing.
sequenceDiagram
autonumber
participant C as MCP client
box rgba(76, 165, 62, 0.1)
participant M as trilium-mcp
end
participant T as Trilium
C->>M: Tool call<br>Authorization: ETAPI token
M->>T: Same token, to /etapi
T-->>M: Notes data
M-->>C: Tool result
Note over M: Holds no secret. The client's<br>token is the only credential.
In detail: tool call in token mode
The middleware rejects any request without an Authorization header. The token is carried per request, in a context variable, from the middleware to the outgoing ETAPI call.
sequenceDiagram
autonumber
participant C as MCP client
box rgba(76, 165, 62, 0.1) trilium-mcp
participant MW as TokenCapture<br>Middleware
participant F as FastMCP<br>tools
participant H as httpx +<br>EtapiTokenAuth
end
participant T as Trilium
Note over C,T: Missing token
C->>MW: POST /mcp, no Authorization
MW-->>C: 401 WWW-Authenticate: Bearer
Note over C,T: Tool call
C->>MW: POST /mcp<br>Authorization: token
MW->>MW: Stash token in<br>_incoming_auth
MW->>F: Forward request
F->>H: Call the mapped<br>ETAPI operation
H->>H: Read token,<br>strip "Bearer "
H->>T: /etapi/…<br>Authorization: raw token
T->>T: Validate token
T-->>H: JSON
H-->>F: Response
F-->>MW: Tool result
MW->>MW: Reset context variable
MW-->>C: Tool result
OAuth¶
A one-time browser login mints an ETAPI token for the client. After that, the client only holds opaque OAuth tokens that map to it.
sequenceDiagram
autonumber
participant C as MCP client
box rgba(76, 165, 62, 0.1)
participant M as trilium-mcp
end
participant T as Trilium
Note over C,T: Once per client
C->>M: Browser login with<br>the Trilium password
M->>T: /auth/login
T-->>M: ETAPI token for this client
M-->>C: OAuth access + refresh token
Note over C,T: Every tool call
C->>M: Tool call<br>Authorization: Bearer access token
M->>T: Mapped ETAPI token, to /etapi
T-->>M: Notes data
M-->>C: Tool result
Note over M: Stores minted tokens encrypted.<br>The client never sees them.
In detail: login (discovery, registration, password, code exchange)
The client discovers the OAuth endpoints from the 401, registers itself, and sends the user to the login page. The Trilium password is exchanged once for a fresh ETAPI token; the client only ever holds opaque tmcp_ tokens that map to it.
sequenceDiagram
autonumber
actor U as User
participant C as MCP client
box rgba(76, 165, 62, 0.1) trilium-mcp
participant A as OAuth<br>provider
participant S as mcp-oauth<br>store
end
participant T as Trilium
Note over U,T: Discovery and registration
C->>A: POST /mcp, no token
A-->>C: 401 with resource metadata
C->>A: GET /.well-known/…<br>POST /register
A->>S: Save client
A-->>C: client_id
Note over U,T: Password login
C->>A: GET /authorize (PKCE)
A->>S: Pending login, 10 min
A-->>U: Redirect to /login
U->>A: POST /login<br>Trilium password
A->>T: POST /auth/login
T-->>A: New ETAPI token
Note over A: The password is used once,<br>never stored or logged.
A->>S: Auth code → ETAPI token
A-->>C: Redirect with code
Note over U,T: Code exchange
C->>A: POST /token<br>code + verifier
A->>S: tmcp_ tokens → ETAPI token
A-->>C: Access token (1 h)<br>refresh token (30 d)
In detail: tool call in oauth or both mode
verify_token maps a tmcp_ token to its ETAPI token. In both mode any other token is passed through as a raw ETAPI token, and an expired tmcp_ token gets a 401 so the client refreshes.
sequenceDiagram
autonumber
participant C as MCP client
box rgba(76, 165, 62, 0.1) trilium-mcp
participant F as FastMCP<br>auth + tools
participant A as verify_token
participant H as httpx +<br>EtapiTokenAuth
end
participant T as Trilium
C->>F: POST /mcp<br>Bearer tmcp_ token or raw ETAPI token
Note over F: both mode: a raw header<br>gets a "Bearer " prefix first
F->>A: verify_token(token)
A->>A: Look up in the<br>mcp-oauth store
alt Issued by us (tmcp_)
A-->>F: etapi_token = mapped token
else Not ours, both mode
A-->>F: etapi_token = the raw token
else Expired or revoked, or oauth mode
A-->>F: None
F-->>C: 401, client refreshes or logs in
end
F->>H: Call the mapped<br>ETAPI operation
H->>H: Read etapi_token<br>from the access token
H->>T: /etapi/…<br>Authorization: ETAPI token
T-->>H: JSON
H-->>F: Response
F-->>C: Tool result
In detail: refresh and revoke
Refreshing rotates the OAuth token pair onto the same ETAPI token. Revoking drops the pair and deletes the ETAPI token in Trilium.
sequenceDiagram
autonumber
participant C as MCP client
box rgba(76, 165, 62, 0.1) trilium-mcp
participant A as OAuth<br>provider
participant S as mcp-oauth<br>store
end
participant T as Trilium
Note over C,T: Refresh, when the access token expires
C->>A: POST /token<br>grant_type=refresh_token
A->>S: Drop the old pair
A->>S: New tmcp_ pair → same ETAPI token
A-->>C: New access + refresh token
Note over C,T: Revoke, to disconnect this client
C->>A: POST /revoke
A->>S: Drop the pair
A->>T: POST /auth/logout
Note over T: Deletes the ETAPI token<br>minted for this client.
Startup and health¶
In detail: startup, startup_error fallback, health check
If the spec or the auth configuration is invalid, the server serves a startup_error-only tool list instead of crashing.
sequenceDiagram
autonumber
actor O as Container runtime
box rgba(76, 165, 62, 0.1) trilium-mcp
participant M as main()
participant F as FastMCP
end
O->>M: Start
M->>M: resolve_auth_mode()
M->>F: build_server()
F->>F: Load the bundled<br>ETAPI OpenAPI spec
F->>F: from_openapi → ~40 tools
F-->>M: Server
Note over M: Invalid spec or auth config:<br>serve startup_error instead.
M->>M: serve() behind the<br>auth-mode gate
Note over O,F: Health check, always unauthenticated
O->>F: GET /health
F-->>O: 200 ok