Standalone remote MCP server for full read/write access to a Zotero library
claude mcp add cite-caddy -- python -m -e{
"mcpServers": {
"cite-caddy": {
"command": "python",
"args": ["-m", "-e"]
}
}
}MCP Servers overview
# Cite Caddy
A reference-library bridge for AI assistants — a standalone, remote MCP
server.
> Independent, unofficial project. Not affiliated with, endorsed by, or
> sponsored by the Corporation for Digital Scholarship (Zotero) —
> see [Zotero's trademark policy](https://www.zotero.org/support/terms/trademark).
> Built on the Zotero Web API; today's backend is Zotero, but the name and
> tool surface are meant to support others later.
Cite Caddy gives full read/write access to a Zotero library — search, add,
tag, update, delete, and move items; create, rename, and delete
collections; upload/download attachments and read their extracted full
text; read and write item notes; manage tags, trash, and saved searches
library-wide; and look up Zotero's own item-type/field schema. 36 tools
total — see [Tools](#tools) below for the full list.
## Why this exists
Read-only tools that match findings against a Zotero library (e.g. by
DOI/arXiv ID) can safely stop at reporting — they never need to write
anything back. This project goes further on purpose: full CRUD against a
Zotero library, including delete and move, so that tagging, adding, and
cleaning up items can be automated too.
That's a deliberate scope choice, and it comes with a real risk: any write
that changes an existing item's *key* (delete, move to another library,
"clean library" reset) breaks Word documents that cite it via the Zotero
Word plugin's live field codes — see "Key safety" below before touching
delete/move.
## Key safety (read this before implementing delete/move)
Any Zotero item cited in a Word document via the Zotero Word plugin is
referenced by that item's **key**, embedded in a live field code. Operations
that preserve an item's key (create, update fields, add/remove tags, add
notes) are safe. Operations that don't (delete, and library-to-library move,
which Zotero implements as delete+recreate) will break those citations
silently — the Word document won't error, it'll just show stale/broken
field text next time someone updates fields or opens Zotero the next time.
Full CRUD was chosen deliberately for this project despite that risk. When
implementing delete/move tools:
- Make the destructive intent obvious in the tool name and docstring (an MCP
client's model reads both before calling), not just in this README.
- Consider requiring the caller to pass back the item's current Zotero
`version` (optimistic concurrency) so a delete/update can't silently clobber
a change made concurrently from the Zotero desktop app or another client.
- A dry-run / confirmation step for delete is worth considering, but is an
implementation decision for whoever builds that tool, not decided here.
## Configuration
**stdio mode** (local, single-user — no `$PORT`): the library to connect to
comes from env vars.
```
ZOTERO_LIBRARY_ID numeric library ID (user or group)
ZOTERO_LIBRARY_TYPE "user" or "group" (default: user)
ZOTERO_API_KEY from Zotero -> Settings -> Security -> Applications
(needs write permission, not just read)
```
**HTTP mode** (remote, multi-tenant — `$PORT` set): there's no single
configured library — each caller brings their own Zotero Library ID/Type/API
Key via the `/login` form (see "Deployment" below). Instead:
```
MCP_TOKEN_STORE_KEY Fernet key encrypting onboarded tenants' API keys at
rest; generate once at deploy time with:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MCP_PUBLIC_URL public HTTPS URL this server is reachable at
MCP_DATA_DIR where OAuth clients/tokens/tenants persist (default: ./.data)
```
Optional in either mode:
```
MCP_WEBSITE_URL public site reported as serverInfo.website_url; also used to
build serverInfo.icons[0].src as MCP_WEBSITE_URL + "icons/icon.svg"
(that file must actually be served there). Left unset, both
fields are simply omitted.
```
## Deployment
Ships as its own Docker container (`docker-compose.yml`), meant to sit
behind a reverse proxy that terminates TLS and forwards to the container's
port on localhost. Remote/hosted by default, not a local stdio server — an
MCP client just points at the URL, nothing to install or run locally.
**Access is gated by a real OAuth 2.1 authorization server built into the
app itself** (`app/oauth_provider.py`), not HTTP Basic Auth in front of it.
This is a deliberate design choice: Claude Desktop/claude.ai's "Add custom
connector" UI is OAuth-first — it always tries the OAuth discovery +
authorization-code dance against a new server, so a plain 401 in front of
the server (as Basic Auth would produce) gets read as "this server needs
OAuth" and fails once it hits a nonexistent `/authorize` endpoint.
Implementing a real (if minimal) OAuth server is what makes "Add custom
connector" work.
**Multi-tenant and self-service**: `/authorize` doesn't delegate to a
third-party identity provider — it shows a first-party login form asking
for a Zotero Library ID, Library Type, and API Key. Submitting the form
validates the key directly against the Zotero API; a successful
validation both grants access and registers ("onboards") that library as
a tenant of this server, all in one step — there's no separate sign-up
and no admin approval. Any MCP client can dynamically register itself
(RFC 7591), but completing the login form with a working Zotero key is
what actually gates access. Each caller's tool calls are then routed to
their own Zotero library, not a shared one. See
`app/oauth_provider.py`'s module docstring for the full flow. Registered
clients, issued tokens, and onboarded tenants' credentials (API keys
encrypted at rest with `MCP_TOKEN_STORE_KEY`) persist to `MCP_DATA_DIR`
(a Docker volume) so redeploys don't log connected clients out or forget
onboarded tenants.
`.env` on the host (not in this repo) holds `MCP_TOKEN_STORE_KEY`/
`MCP_PUBLIC_URL`, consumed via `docker-compose.yml`'s `env_file:`.
`ZOTERO_LIBRARY_ID`/`ZOTERO_LIBRARY_TYPE`/`ZOTERO_API_KEY` are not needed
for the HTTP deployment — those only apply to stdio mode.
`.github/workflows/deploy.yml` automates redeploying to an already
set-up host: manual trigger only (`workflow_dispatch`, never on push),
runs the test suite first, then syncs the repo over SSH and rebuilds the
container. It needs its own GitHub Actions **secrets** for the deploy SSH
key and target host/port/user — see the workflow file for the full list.
Use a dedicated deploy key (not whatever key you use for direct/manual
access), so it can be revoked independently if it ever leaks.
## Tools
Read-only: `search_items`, `get_item`, `list_collections`, `list_tags`,
`list_trash`, `list_saved_searches`, `list_groups`, `list_item_types`,
`list_item_fields`, `list_item_type_fields`, `list_item_creator_types`,
`list_attachments`, `get_fulltext`, `download_attachment`, `list_notes`.
`list_groups` lists the groups the configured API key's account belongs
to; its `id` doubles as `target_library_id` (with
`target_library_type="group"`) for `move_item_to_different_library`.
`list_item_type_fields`/`list_item_creator_types` describe what
`create_item`/`update_item` will actually accept for a given
`item_type` -- check them instead of guessing when a validation error
comes back. `search_items`' `query` defaults to Zotero's quick search
(title/creator/year); pass `full_text=True` to also match the indexed
content of attached files and notes (Zotero's `qmode="everything"`),
requiring a non-empty `query`.
Safe, key-preserving writes: `create_item`, `create_collection`,
`create_saved_search`, `update_collection`, `update_item`,
`add_tags`/`remove_tags`/`set_tags`, `rename_tag`,
`add_to_collection`/`remove_from_collection`, `trash_item`,
`restore_from_trash`, `upload_attachment`, `create_note`, `update_note`.
`trash_item` is a reversible soft delete (undo with `restore_from_trash`)
-- unlike `delete_item_permanently` below, it doesn't break Word
citations unless the item is later permanently deleted or the trash is
emptied.
Destructive (see "Key safety" above): `delete_item_permanently`,
`move_item_to_different_library`, `delete_collection`, `delete_tag`,
`delete_saved_search`. The first three require the item's/collection's
current Zotero `version` (get it from
`search_items`/`get_item`/`list_collections` first) and refuse the call if
it doesn't match the server's current version, rather than silently
overwriting a concurrent change. `delete_collection` cascades to any
sub-collections (matching Zotero's own "Delete Collection" behavior) but
never deletes the items filed in them. `rename_tag`/`delete_tag` act on
every item in the library carrying that tag, not just one -- there's no
per-tag version to check, since tags aren't standalone versioned entities
in Zotero's API. `delete_saved_search` is destructive but low-risk: a
saved search is just a stored filter, so deleting one never touches items
or citations.
Attachment file content (`upload_attachment`'s `content_base64` argument,
`download_attachment`'s `content_base64` result) travels base64-encoded —
this server is remote and has no access to the caller's local filesystem, so
raw bytes can't be passed as a local path the way pyzotero itself expects.
## Testing
```
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
pytest
```
Tests never call a live Zotero library, even if `.env` has real
credentials: `app/zotero_service.py` (all Zotero read/write logic) is
exercised against `tests/fakes.py`'s in-memory `FakeZotero`, and
`app/mcp_server.py`'s tool functions are tested directly against a
`ZoteroService` backed by that fake (see `configure_service()`).
## Status
**v1.5** — deployed and in active use, with full CRUD coverage of the
Zotero Web API's item/collection/tag/trash/saved-search/schema surface
(36 tools; see [Tools](#tools)). Add it as a remoWhat people ask about cite-caddy
What is herbertkokholm/cite-caddy?
+
herbertkokholm/cite-caddy is mcp servers for the Claude AI ecosystem. Standalone remote MCP server for full read/write access to a Zotero library It has 0 GitHub stars and was last updated today.
How do I install cite-caddy?
+
You can install cite-caddy by cloning the repository (https://github.com/herbertkokholm/cite-caddy) or following the README instructions on GitHub. ClaudeWave also provides quick install blocks on this page.
Is herbertkokholm/cite-caddy safe to use?
+
herbertkokholm/cite-caddy has not been audited yet by our security agent. Review the original repository on GitHub before using it in production.
Who maintains herbertkokholm/cite-caddy?
+
herbertkokholm/cite-caddy is maintained by herbertkokholm. The last recorded GitHub activity is from today, with 0 open issues.
Are there alternatives to cite-caddy?
+
Yes. On ClaudeWave you can browse similar mcp servers at /categories/mcp, sorted by popularity or recent activity.
Deploy cite-caddy to your cloud
Ship this repo to production in minutes. Each platform spins up its own environment with editable env vars.
Maintain this repo? Add a badge to your README
Drop the badge into your GitHub README to show it's tracked on ClaudeWave. Each badge links back to this page and reflects the live Trust Score.
[](https://claudewave.com/repo/herbertkokholm-cite-caddy)<a href="https://claudewave.com/repo/herbertkokholm-cite-caddy"><img src="https://claudewave.com/api/badge/herbertkokholm-cite-caddy" alt="Featured on ClaudeWave: herbertkokholm/cite-caddy" width="320" height="64" /></a>More MCP Servers
Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.
User-friendly AI Interface (Supports Ollama, OpenAI API, ...)
An open-source AI agent that brings the power of Gemini directly into your terminal.
The fastest path to AI-powered full stack observability, even for lean teams.
Real-time global intelligence dashboard. AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface
🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!