使用说明
Joplin Server MCP
Model Context Protocol server for Joplin Server. Gives LLMs full access to your notes, notebooks, tags, and attachments via the Joplin Server REST API.
What is Joplin Server?
Joplin is an open-source note-taking app with Markdown support, end-to-end encryption, and sync across devices. Joplin Server is the sync backend that stores and syncs your data. You can run it yourself using the official Docker image or use the managed Joplin Cloud service.
Note: This MCP server connects to Joplin Server REST API, not the Joplin Desktop Web Clipper.
Joplin Cloud: Use
JOPLIN_SERVER_URL=https://api.joplincloud.com.
Tools
| Tool | Description |
|---|---|
ping_joplin |
Check server connectivity |
list_notebooks |
List all notebooks |
get_notebook |
Get notebook details with notes and sub-notebooks |
create_notebook |
Create a new notebook |
get_or_create_notebook |
Resolve a /-separated notebook path, creating missing levels |
update_notebook |
Rename or move a notebook (with circular reference check) |
delete_notebook |
Delete a notebook (with optional force for non-empty) |
list_notes |
List notes, optionally filtered by notebook, tag, and/or to-do state |
get_all_notes |
Get all notes with pagination, sorting (incl. todo_due/todo_completed), notebook and to-do filters |
search_notes |
Search notes (multi-term AND) with scope and notebook/tag filters |
get_note |
Get note text (resource references replaced with names, or raw=True for the verbatim body) |
get_notes_batch |
Read multiple notes at once (up to 50, parallel) |
get_note_full |
Get note with all resources embedded as base64 |
create_note |
Create a new note (optionally as a to-do with a due date) |
export_note |
Export note as markdown with resources as named base64 blocks |
update_note |
Update note title, body, or move to another notebook |
get_note_outline |
Map a note's headings with line numbers and section sizes |
append_to_note |
Add text inside a note or section, or as a sibling before/after a section |
replace_in_note |
Replace an exact string inside a note body |
replace_section |
Replace everything under a heading |
set_todo |
Set/clear a note's to-do state, completion, and due date |
delete_note |
Delete a note |
list_tags |
List all tags |
create_tag |
Create a new tag |
delete_tag |
Delete a tag |
get_note_tags |
List tags assigned to a note |
add_tag_to_note |
Add a tag to a note |
remove_tag_from_note |
Remove a tag from a note |
get_note_resources |
List resources attached to a note |
get_resource_info |
Get resource metadata |
download_resource |
Download a resource as base64 |
Partial edits
update_note takes the complete new body, which makes it a poor fit for large
notes: adding two lines to a 70 KB note means an LLM has to reproduce all 70 KB
verbatim. That is expensive, and a single silent typo corrupts the note.
append_to_note, replace_in_note and replace_section resolve the edit on
the server instead. The client sends only the fragment it wants added or
changed, so untouched text — including :/<resource-id> links, which the read
tools render as human-readable names — is preserved byte for byte.
append_to_note(note_id, "| web-1 | 10.0.0.5 |", section="Hosts", separator="\n")
append_to_note(note_id, "## 2026-08-26\n\nDeployed.", section="## 2026-08-25", position="before")
replace_in_note(note_id, "status: draft", "status: final")
replace_section(note_id, "## Current state", "Rewritten from scratch.")
Every write reports what it did — character and line deltas, heading count before and after, and the numbered lines around the change — so the result can be verified without re-reading the note:
Appended to: **Work Log** (ID: `0123456789abcdef0123456789abcdef`)
Inserted 745 chars at the start of section '# Work Log' (line 1)
Chars 76141 -> 76887 (+746), lines 1858 -> 1885 (+27), headings 117 -> 118
Guard rails:
- Sections are addressed by heading, either bare (
"Hosts") or with its level ("## Hosts"), and a section ends at the next heading of the same or higher level. Headings inside fenced code blocks are ignored. An ambiguous or missing heading is refused, never guessed.get_note_outlinelists what is available without reading the body. positionpicks inside or beside."start"/"end"write inside the note or section, joining the existing content withseparator."before"/"after"place the text beside a named section instead — above its heading, or past its whole span with subsections included. Those two requiresection, ignoreseparator, and are strictly additive: no existing byte is rewritten and blank lines appear only where the join needs them. Reach for"before"to put a new entry at the top of a newest-first log whose first heading is preceded by a preamble —"start"would land above the preamble.replace_in_notedemands a unique match. A missing anchor or an unexpected second match is an error, with the mismatching lines reported. Useget_note(raw=True)to copy an anchor verbatim, orreplace_all=Truewhen you do mean every occurrence.dry_run=Truereports the change and its context without writing.if_absent="marker"skips the append when the marker is already in the note, which makes a re-run a no-op.
Setup
Prerequisites
- A running Joplin Server instance or Joplin Cloud account
- User credentials (email + password)
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
JOPLIN_SERVER_URL |
Yes | — | Joplin Server URL |
JOPLIN_EMAIL |
Yes | — | User email |
JOPLIN_PASSWORD |
Yes | — | User password |
MCP_TRANSPORT |
No | stdio (sse in the Docker image) |
Transport: stdio, sse or streamable-http |
MCP_HOST |
No | 0.0.0.0 |
HTTP listen host (sse and streamable-http) |
MCP_PORT |
No | 8081 |
HTTP listen port (sse and streamable-http) |
streamable-http serves the endpoint /mcp and is the transport the current
MCP specification recommends; sse (endpoint /sse) is the older one, kept as
the image default for compatibility.
Run with Docker
docker run -d \
-e JOPLIN_SERVER_URL=https://your-joplin-server.example.com \
-e JOPLIN_EMAIL=your@email.com \
-e JOPLIN_PASSWORD=your_password \
-p 8081:8081 \
alexfail2/joplin-mcp
The container defaults to SSE transport. The endpoint will be available at http://localhost:8081/sse.
Add -e MCP_TRANSPORT=streamable-http to serve http://localhost:8081/mcp instead.
Run locally (stdio)
pip install -r requirements.txt # mcp, httpx, uvicorn, pinned
python app/server.py
Build from source
docker build -t joplin-mcp .
MCP client configuration
Streamable HTTP
{
"mcpServers": {
"joplin": {
"url": "http://localhost:8081/mcp"
}
}
}
SSE (Docker default)
{
"mcpServers": {
"joplin": {
"url": "http://localhost:8081/sse"
}
}
}
stdio (local)
{
"mcpServers": {
"joplin": {
"command": "python",
"args": ["/path/to/app/server.py"],
"env": {
"JOPLIN_SERVER_URL": "https://your-joplin-server.example.com",
"JOPLIN_EMAIL": "your@email.com",
"JOPLIN_PASSWORD": "your_password"
}
}
}
}
How it works
The server authenticates with Joplin Server via email/password sessions and builds an in-memory index of all items (notes, notebooks, tags) with a 2-minute TTL cache. Incremental sync compares server-side updated_time with cached etags, fetching only changed items (typical refresh: ~5s vs ~35s full rebuild). The index is persisted to /tmp/joplin_index_cache.json so a restarted container starts instantly; a recreated one rebuilds it on first use. Resource metadata is loaded lazily on first access. Background refresh keeps the index up to date without blocking requests. All IDs are validated (32-char hex) before API calls.
Joplin's internal serialization format (title + markdown body + metadata block) is parsed and presented as clean structured output.
Development
The pure helpers (markdown section resolution, splicing, edit reports, to-do state) are covered by a dependency-free test suite:
python tests/test_helpers.py
Contributors
- @paoloviviani — to-do state: completion and
due dates surfaced and settable,
set_todo,create_noteflags,todofilters and ordering (#7) - @LasseLegarth —
share_idinherited from the parent notebook, so notes stay visible to a shared notebook's participants (#5)
