Skip to main content
Glama
README.md
# joplin-mcp

An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that exposes
[Joplin](https://joplinapp.org)'s Data API (the "Web Clipper" REST API) as a set of tools
an LLM can call — search, read, create, update, and organize your Joplin notes, notebooks,
and tags.

Built to run against a **headless Joplin instance** (e.g.
[`jspiers/headless-joplin`](https://github.com/jspiers/headless-joplin)) that syncs to a
[Joplin Server](https://joplinapp.org/help/apps/sync/) backend, rather than talking to
Joplin Server's own API directly (which is sync-protocol only and doesn't expose notes in
a friendly way).

Transport is Streamable HTTP, so it works with any MCP client that supports that transport
— tested against [Open WebUI](https://openwebui.com) (v0.6.31+, which added native MCP
support) and Claude.

## Tools

**Notes**
- `search_notes` — free-text search with optional `notebook` / `tag` / `is_todo` filters
- `get_note` — fetch full content of a note by id
- `list_all_notes` — paginated listing across every notebook
- `list_notes_in_notebook` — notes inside a specific notebook
- `list_recent_changes` — notes ordered by most recently modified
- `create_note`
- `update_note` — title/body/to-do state, and can move a note between notebooks
- `delete_note`

**Notebooks**
- `list_notebooks`
- `create_notebook`
- `update_notebook` — rename and/or reparent
- `delete_notebook`

**Tags**
- `list_tags`
- `get_notes_by_tag`
- `get_note_tags`
- `tag_note` — creates the tag if it doesn't already exist
- `untag_note`
- `rename_tag`
- `delete_tag`

**Other**
- `list_note_resources` — list attachments on a note
- `ping_joplin` — health check

## Architecture

```
LLM client (OWUI / Claude / etc.)
        │  Streamable HTTP + bearer auth
        ▼
   joplin-mcp  (this repo)
        │  Joplin Data API (HTTP, token auth)
        ▼
  headless Joplin instance (jspiers/headless-joplin)
        │  Joplin sync protocol
        ▼
   Joplin Server  ◄──sync──►  Joplin desktop/mobile apps
```

The headless Joplin container is required because Joplin Server itself only speaks the
sync protocol — actual note content is only queryable through a running Joplin
client's Data API, which is why this sits between the MCP server and Joplin Server.

## Configuration

Environment variables:

| Variable | Description | Default |
|---|---|---|
| `JOPLIN_API_URL` | Base URL of the Joplin Data API | `http://localhost:41184` |
| `JOPLIN_TOKEN` | Joplin Web Clipper auth token | *(required)* |
| `MCP_API_KEY` | Bearer token required on every request to this server. If unset, the server accepts unauthenticated requests — only safe for a fully internal network. | *(unset)* |
| `MCP_TRANSPORT` | `stdio` or `streamable-http` | `streamable-http` |
| `MCP_PORT` | Port for streamable-http transport | `8080` |

> **Note on `jspiers/headless-joplin`:** that image's `api.port` is `41184` *inside* the
> container, but only bound to `127.0.0.1` there — it re-exposes it externally on port
> **80** via `socat`. So `JOPLIN_API_URL` should point at `http://<headless-container>:80`,
> not `:41184`, when running against that image over a Docker network.
>
> Also note that image hard-codes `api.token: "mytoken"` as a config *default* that gets
> reapplied on every container start. To set a real token that survives restarts, put
> `api.token` in the JSON config file mounted into that container (see its
> [README](https://github.com/jspiers/headless-joplin)), not via `joplin config` at runtime.

## Running

### Docker

```bash
docker build -t joplin-mcp:latest .
docker run -d --name joplin-mcp \
  -e JOPLIN_API_URL=http://joplin-headless:80 \
  -e JOPLIN_TOKEN=<your-token> \
  -e MCP_API_KEY=<a-random-key> \
  -p 8080:8080 \
  joplin-mcp:latest
```

### docker-compose

See [`docker-compose.example.yml`](./docker-compose.example.yml) for a full example
alongside a headless Joplin container and Joplin Server, including Traefik labels for
reverse-proxying it with a public hostname + TLS.

You'll also need a Joplin sync config file for the headless container — copy
[`joplin-headless-config.example.json`](./joplin-headless-config.example.json) to
`joplin-headless-config.json` (same directory as your compose file, matching the bind
mount in the example) and fill in your real sync server URL, account credentials, and a
generated `api.token`. This file is gitignored so your credentials don't get committed.

### Locally (stdio, for use with Claude Desktop etc.)

```bash
pip install -r requirements.txt
JOPLIN_API_URL=http://localhost:41184 JOPLIN_TOKEN=... MCP_TRANSPORT=stdio python server.py
```

## Syncing the headless client

`jspiers/headless-joplin` hardcodes `sync.interval: 0` (disabled) as a config default that
gets reapplied on every container start — so **automatic interval-based sync is not
possible through Joplin's own config on this image**, regardless of what you set in
`joplin-headless-config.json`.

To get notes created/edited via this MCP server to actually reach Joplin Server (and from
there, your desktop/mobile apps), trigger `joplin sync` periodically from outside the
container — a host crontab entry is the simplest approach:

```cron
*/5 * * * * docker exec joplin-headless joplin sync >> /var/log/joplin-sync.log 2>&1
```

Adjust the interval to taste. Without this (or an equivalent scheduled sync), changes only
propagate when you manually run `docker exec joplin-headless joplin sync`.

## Connecting from Open WebUI

1. **Admin Settings → External Tools → Add Server**
2. **Type:** `MCP (Streamable HTTP)`
3. **Server URL:** `https://<your-host>/mcp`
4. **Auth:** Bearer token — the value of `MCP_API_KEY`

## Security notes

- If exposing this publicly (e.g. behind a reverse proxy with a public hostname), **always
  set `MCP_API_KEY`**. Without it, anyone who can reach the URL can read/write/delete your
  notes.
- The server disables the MCP SDK's built-in Host-header DNS-rebinding check
  (`enable_dns_rebinding_protection=False`), since it's expected to run behind a reverse
  proxy with a hostname the SDK wouldn't otherwise trust. This is safe specifically
  *because* the bearer-token middleware provides the actual access control instead — don't
  disable both.

## License

MIT — see [LICENSE](./LICENSE).