joplin-mcp — Notes for AI Agents
the design choices behind a small Go server that exposes my Joplin vault to Claude, Cursor, and any other MCP-aware agent.
The shortest version of this post: I built joplin-mcp because I wanted to ask Claude about my own notes the same way I ask it about my code, and the official Joplin app only exposes its data via a local HTTP API that nothing else knew how to talk to.
The longer version is about three design decisions I’m glad I made.
One: Every Write Op Takes an ID, Not a Name
Joplin doesn’t enforce unique titles. I have eleven notes called “TODO” and four called “Meeting.” If the agent could write to update_note(title="TODO", body=...) it would eventually overwrite the wrong one. So the schema requires the ID: update_note(id="abc123", body=...). The agent has to do search_notes or list_notes first, surface the candidates to me, get my pick, then write.
One extra round-trip; zero ambient catastrophes.
Two: Batch Operations Are First-Class
A naive MCP server gives you tag_note(note_id, tag) and the agent calls it 50 times. That works but it’s slow and noisy in the chat log.
So the server also exposes batch_tag_notes(operations: [{note_id, tag}, ...]). Same surface area on the Joplin side, dramatically better UX. Same pattern for batch_move_notes and batch_import_markdown.
Three: Resources, Not Just Tools
MCP has both: tools (the agent calls a function) and resources (the agent reads a URI). Joplin folders are exposed as resources at joplin://folder/<id> so the agent can attach a folder to its context without me having to manually paste.
This makes the difference between “agent can technically read my notes” and “agent treats my notes the way it treats files.”
The Result
The whole thing is ~1,400 lines of Go, depends on mcp-go, and ships as a single binary via my homebrew tap. The full story of why I went Joplin-first instead of, say, Obsidian, is in the project page.