{
  "markdown": "# Cite Caddy\n\n[![cite-caddy MCP server](https://glama.ai/mcp/servers/herbertkokholm/cite-caddy/badges/score.svg)](https://glama.ai/mcp/servers/herbertkokholm/cite-caddy)\n[![smithery badge](https://smithery.ai/badge/herbertkokholm/cite-caddy)](https://smithery.ai/servers/herbertkokholm/cite-caddy)\n[![MCP Registry](https://img.shields.io/badge/MCP%20Registry-listed-blue)](https://registry.modelcontextprotocol.io)\n[![Lint](https://img.shields.io/github/actions/workflow/status/herbertkokholm/cite-caddy/lint.yml?branch=main&label=lint)](https://github.com/herbertkokholm/cite-caddy/actions/workflows/lint.yml)\n[![Latest release](https://img.shields.io/github/v/release/herbertkokholm/cite-caddy)](https://github.com/herbertkokholm/cite-caddy/releases)\n[![License: MIT](https://img.shields.io/github/license/herbertkokholm/cite-caddy)](LICENSE)\n\nA reference-library bridge for AI assistants — a standalone, remote MCP\nserver.\n\n> Independent, unofficial project. Not affiliated with, endorsed by, or\n> sponsored by the Corporation for Digital Scholarship (Zotero) —\n> see [Zotero's trademark policy](https://www.zotero.org/support/terms/trademark).\n> Built on the Zotero Web API; today's backend is Zotero, but the name and\n> tool surface are meant to support others later.\n\nCite Caddy gives full read/write access to a Zotero library — search, add,\ntag, update, delete, and move items; create, rename, and delete\ncollections; upload/download attachments and read their extracted full\ntext; read and write item notes; manage tags, trash, and saved searches\nlibrary-wide; and look up Zotero's own item-type/field schema. 39 tools\ntotal — see [Tools](#tools) below for the full list.\n\n## Why this exists\n\nRead-only tools that match findings against a Zotero library (e.g. by\nDOI/arXiv ID) can safely stop at reporting — they never need to write\nanything back. This project goes further on purpose: full CRUD against a\nZotero library, including delete and move, so that tagging, adding, and\ncleaning up items can be automated too.\n\nThat's a deliberate scope choice, and it comes with a real risk: any write\nthat changes an existing item's *key* (delete, move to another library,\n\"clean library\" reset) breaks Word documents that cite it via the Zotero\nWord plugin's live field codes — see \"Key safety\" below before touching\ndelete/move.\n\n## Key safety (read this before implementing delete/move)\n\nAny Zotero item cited in a Word document via the Zotero Word plugin is\nreferenced by that item's **key**, embedded in a live field code. Operations\nthat preserve an item's key (create, update fields, add/remove tags, add\nnotes) are safe. Operations that don't (delete, and library-to-library move,\nwhich Zotero implements as delete+recreate) will break those citations\nsilently — the Word document won't error, it'll just show stale/broken\nfield text next time someone updates fields or opens Zotero the next time.\n\nFull CRUD was chosen deliberately for this project despite that risk. When\nimplementing delete/move tools:\n- Make the destructive intent obvious in the tool name and docstring (an MCP\n  client's model reads both before calling), not just in this README.\n- Consider requiring the caller to pass back the item's current Zotero\n  `version` (optimistic concurrency) so a delete/update can't silently clobber\n  a change made concurrently from the Zotero desktop app or another client.\n- A dry-run / confirmation step for delete is worth considering, but is an\n  implementation decision for whoever builds that tool, not decided here.\n\n## Idempotency\n\n`delete_item_permanently`, `delete_collection`, `delete_tag`,\n`delete_saved_search`, `move_item_to_different_library`, and\n`update_publication_status` all accept an optional `idempotency_key`. Pass\nthe same opaque string when retrying a call after a lost or ambiguous\nresponse (e.g. a network timeout) and the *original* outcome — success or\nerror — is replayed instead of running the operation against Zotero again.\nReusing a key for a call with different arguments raises an error instead of\nsilently returning the old result, so it's safe to generate one key per\nlogical request and reuse it freely on retries of that same request.\n\nThis matters most for `move_item_to_different_library`: it recreates the\nitem in the target library, then deletes it from the source. If the create\nsucceeds but the delete then fails, a bare retry would redo the whole thing\n— since the source item's version hasn't changed — creating a *second*\nduplicate in the target library. With `idempotency_key`, the retry replays\nthe cached failure (and its \"clean up manually\" guidance) instead of\ntouching Zotero again.\n\nThe cache is in-memory per server process (per onboarded tenant in HTTP\nmode), with a 24h TTL — it survives retries within that window, not across a\nredeploy/restart.\n\n## Configuration\n\n**stdio mode** (local, single-user — no `$PORT`): the library to connect to\ncomes from env vars.\n\n```\nZOTERO_LIBRARY_ID      numeric library ID (user or group)\nZOTERO_LIBRARY_TYPE    \"user\" or \"group\" (default: user)\nZOTERO_API_KEY         from Zotero -> Settings -> Security -> Applications\n                        (needs write permission, not just read)\n```\n\n**HTTP mode** (remote, multi-tenant — `$PORT` set): there's no single\nconfigured library — each caller brings their own Zotero Library ID/Type/API\nKey via the `/login` form (see \"Deployment\" below). Instead:\n\n```\nMCP_TOKEN_STORE_KEY    Fernet key encrypting onboarded tenants' API keys at\n                        rest; generate once at deploy time with:\n                        python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"\nMCP_PUBLIC_URL          public HTTPS URL this server is reachable at\nMCP_DATA_DIR            where OAuth clients/tokens/tenants persist (default: ./.data)\n```\n\nOptional in either mode:\n\n```\nMCP_WEBSITE_URL         public site reported as serverInfo.website_url; also used to\n                        build serverInfo.icons[0].src as MCP_WEBSITE_URL + \"icons/icon.svg\"\n                        and the /login page's privacy policy link as\n                        MCP_WEBSITE_URL + \"privacy.html\" (both files must actually be\n                        served there). Left unset, all three are simply omitted.\n```\n\n## Deployment\n\nShips as its own Docker container (`docker-compose.yml`), meant to sit\nbehind a reverse proxy that terminates TLS and forwards to the container's\nport on localhost. Remote/hosted by default, not a local stdio server — an\nMCP client just points at the URL, nothing to install or run locally.\n\n**Access is gated by a real OAuth 2.1 authorization server built into the\napp itself** (`app/oauth_provider.py`), not HTTP Basic Auth in front of it.\nThis is a deliberate design choice: Claude Desktop/claude.ai's \"Add custom\nconnector\" UI is OAuth-first — it always tries the OAuth discovery +\nauthorization-code dance against a new server, so a plain 401 in front of\nthe server (as Basic Auth would produce) gets read as \"this server needs\nOAuth\" and fails once it hits a nonexistent `/authorize` endpoint.\nImplementing a real (if minimal) OAuth server is what makes \"Add custom\nconnector\" work.\n\n**Multi-tenant and self-service**: `/authorize` doesn't delegate to a\nthird-party identity provider — it shows a first-party login form asking\nfor a Zotero Library ID, Library Type, and API Key. Submitting the form\nvalidates the key directly against the Zotero API; a successful\nvalidation both grants access and registers (\"onboards\") that library as\na tenant of this server, all in one step — there's no separate sign-up\nand no admin approval. Any MCP client can dynamically register itself\n(RFC 7591), but completing the login form with a working Zotero key is\nwhat actually gates access. Each caller's tool calls are then routed to\ntheir own Zotero library, not a shared one. See\n`app/oauth_provider.py`'s module docstring for the full flow. Registered\nclients, issued tokens, and onboarded tenants' credentials (API keys\nencrypted at rest with `MCP_TOKEN_STORE_KEY`) persist to `MCP_DATA_DIR`\n(a Docker volume) so redeploys don't log connected clients out or forget\nonboarded tenants.\n\n`.env` on the host (not in this repo) holds `MCP_TOKEN_STORE_KEY`/\n`MCP_PUBLIC_URL`, consumed via `docker-compose.yml`'s `env_file:`.\n`ZOTERO_LIBRARY_ID`/`ZOTERO_LIBRARY_TYPE`/`ZOTERO_API_KEY` are not needed\nfor the HTTP deployment — those only apply to stdio mode.\n\n`.github/workflows/deploy.yml` automates redeploying to an already\nset-up host: manual trigger only (`workflow_dispatch`, never on push),\nruns the test suite first, then syncs the repo over SSH and rebuilds the\ncontainer. It needs its own GitHub Actions **secrets** for the deploy SSH\nkey and target host/port/user — see the workflow file for the full list.\nUse a dedicated deploy key (not whatever key you use for direct/manual\naccess), so it can be revoked independently if it ever leaks.\n\n## Monitoring\n\nFour unauthenticated GET endpoints, HTTP mode only (all require `$PORT`,\nsame as `/login`):\n\n- **`/healthz`** — plain `200 OK`, for a load balancer/uptime check.\n- **`/status`** — JSON snapshot of aggregate, process-level activity:\n\n  ```json\n  {\n    \"version\": \"2.1.0\",\n    \"uptime_seconds\": 41213,\n    \"tenants\": 7,\n    \"tool_calls\": {\"search_items\": 512, \"add_tags\": 41},\n    \"tool_errors\": {\"add_tags\": 2}\n  }\n  ```\n\n  `tenants` is `TokenStore.tenant_count()` — just the number of onboarded\n  Zotero libraries. `tool_calls`/`tool_errors` are per-tool-name counts\n  across *all* tenants combined, recorded by a `tools/call` middleware\n  (`_track_tool_call` in `app/mcp_server.py`). Deliberately no per-tenant or\n  per-library breakdown anywhere in this response — that's what keeps it\n  safe to leave unauthenticated, unlike the 39 tools themselves. Counters\n  live in `app/metrics.py`, in-memory only: they reset to zero on every\n  restart/redeploy, same as this isn't a metrics/analytics system, just a\n  lightweight \"is it up and roughly how busy is it\" signal.\n- **`/status.html`** — same data as `/status`, rendered as a small page\n  (name + icon, one table of version/uptime/tenants, one table of\n  per-tool call/error counts) for a human checking in a browser rather\n  than a script. Icon only renders when `MCP_WEBSITE_URL` is set, same\n  as `/login`'s.\n- **`/.well-known/mcp/server-card.json`** — a pre-connection discovery\n  document (server identity, auth requirements, and the full tool list\n  with schemas), generated live from the actual tool registry on every\n  request so it can't drift out of sync. **Not a ratified standard**: this\n  is a pragmatic approximation of\n  [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127)\n  (\"MCP Server Cards — HTTP Server Discovery\", superseding the withdrawn\n  SEP-1649), which is still an open, unmerged proposal as of this\n  writing — even its own well-known path has changed between drafts (some\n  revisions use `.well-known/ai-catalog.json` instead) — it's not a general\n  claim of spec compliance, and may need to change if/when SEP-2127 (or a\n  successor) actually ratifies with a different contract.\n\n## Tools\n\n39 tools total, grouped by risk (see \"Key safety\" above before using any\nDestructive tool). Tools marked **✓** under **Version** require the\nitem's/collection's current Zotero `version` (from\n`search_items`/`get_item`/`list_collections`) as an argument and refuse\nthe call if it's stale, rather than silently overwriting a concurrent\nchange.\n\n| Tool | Category | Version | Notes |\n|---|---|:---:|---|\n| `search_items` | Read-only | | `query` defaults to Zotero's quick search (title/creator/year); `full_text=True` also matches indexed content of attached files/notes (`qmode=\"everything\"`), requires non-empty `query`. Each result's `creators` is a list of `{creatorType, firstName, lastName}` (or `{creatorType, name}` for single-field/institutional creators) entries — same shape `create_item`/`update_item` accept, preserving role (author vs. editor vs. translator, ...). |\n| `get_item` | Read-only | | `creators` shape as above. |\n| `list_collections` | Read-only | | |\n| `list_tags` | Read-only | | |\n| `list_trash` | Read-only | | `creators` shape as above. |\n| `list_saved_searches` | Read-only | | |\n| `list_groups` | Read-only | | `id` doubles as `target_library_id` (with `target_library_type=\"group\"`) for `move_item_to_different_library`. |\n| `list_item_types` | Read-only | | |\n| `list_item_fields` | Read-only | | |\n| `list_item_type_fields` | Read-only | | Check before `create_item`/`update_item` instead of guessing — what fields a given `item_type` accepts. |\n| `list_item_creator_types` | Read-only | | Same, for `creators` entries' `creatorType`. |\n| `list_creator_fields` | Read-only | | Name-shape fields (`firstName`, `lastName`, `name`, ...) valid on a `creators` entry itself — not the same as `list_item_creator_types` (roles). |\n| `list_attachments` | Read-only | | |\n| `get_fulltext` | Read-only | | |\n| `download_attachment` | Read-only | | Content returned as `content_base64` — server has no access to the caller's local filesystem. |\n| `list_notes` | Read-only | | |\n| `export_bibliography` | Read-only | | Formatted HTML bibliography/citation entries (in a given CSL `style`) or portable export data (`csljson`, `bibtex`) for a list of item keys. Unknown keys silently omitted. |\n| `create_item` | Safe write | | |\n| `create_collection` | Safe write | | |\n| `create_saved_search` | Safe write | | |\n| `update_collection` | Safe write | ✓ | |\n| `update_item` | Safe write | ✓ | |\n| `update_publication_status` | Safe write | ✓ | Preprint → published: patches fields and, uniquely, `item_type` in place. Accepts `idempotency_key` — see \"Idempotency\" above. |\n| `add_tags` | Safe write | ✓ | |\n| `remove_tags` | Safe write | ✓ | |\n| `set_tags` | Safe write | ✓ | |\n| `rename_tag` | Safe write | | Library-wide — acts on every item carrying the tag, not just one; no per-tag version. Can block on large libraries: [#6](https://github.com/herbertkokholm/cite-caddy/issues/6). |\n| `add_to_collection` | Safe write | ✓ | |\n| `remove_from_collection` | Safe write | ✓ | |\n| `trash_item` | Safe write | ✓ | Reversible soft delete — undo with `restore_from_trash`; doesn't break Word citations unless later permanently deleted or the trash is emptied. |\n| `restore_from_trash` | Safe write | ✓ | |\n| `upload_attachment` | Safe write | | Content sent as `content_base64` — server has no access to the caller's local filesystem. |\n| `create_note` | Safe write | | |\n| `update_note` | Safe write | ✓ | |\n| `delete_item_permanently` | Destructive | ✓ | Breaks Word citations. Accepts `idempotency_key` — see \"Idempotency\" above. |\n| `move_item_to_different_library` | Destructive | ✓ | Recreates the item under a brand-new key in the target library, then deletes the original — breaks Word citations. Accepts `idempotency_key`, strongly recommended here — see \"Idempotency\" above. |\n| `delete_collection` | Destructive | ✓ | Cascades to sub-collections (matching Zotero's own \"Delete Collection\"); never deletes the items filed in them. Accepts `idempotency_key`. |\n| `delete_tag` | Destructive | | Library-wide — acts on every item carrying the tag, not just one; no per-tag version. Accepts `idempotency_key`. |\n| `delete_saved_search` | Destructive | | Low-risk — a saved search is just a stored filter, never touches items or citations. Accepts `idempotency_key`. |\n\n## Testing\n\n```\nuv venv && source .venv/bin/activate\nuv pip install -e \".[dev]\"\npytest\n```\n\nTests never call a live Zotero library, even if `.env` has real\ncredentials: `app/zotero_service.py` (all Zotero read/write logic) is\nexercised against `tests/fakes.py`'s in-memory `FakeZotero`, and\n`app/mcp_server.py`'s tool functions are tested directly against a\n`ZoteroService` backed by that fake (see `configure_service()`).\n\n## Status\n\n**v2.3** — deployed and in active use, with full CRUD coverage of the\nZotero Web API's item/collection/tag/trash/saved-search/schema surface\n(39 tools; see [Tools](#tools)). Add it as a remote MCP connector directly\n(e.g. Claude Desktop/claude.ai's \"Add custom connector\" with just the\nserver's public URL) — the OAuth flow described above prompts for your\nown Zotero Library ID/Type/API Key in-browser, no manually-configured\nheaders needed, and no admin sign-up step.\n\nListed in the official [MCP Registry](https://registry.modelcontextprotocol.io)\nas `dk.herbertkokholm.citecaddy/cite-caddy` — metadata lives in\n[`server.json`](server.json), published via `mcp-publisher` and DNS-verified\nagainst `citecaddy.herbertkokholm.dk`. Not (yet) part of GitHub's separate,\nmanually-curated [github.com/mcp](https://github.com/mcp) directory, which\ndoesn't sync automatically from the open registry.\n\n## Known limitations\n\nTracked gaps against the MCP [2026-07-28 specification](https://blog.modelcontextprotocol.io/posts/2026-07-28/)\n(\"stateless core, enterprise authorization, extensions framework\"). None\nare currently exploitable or user-facing — each is either inert until an\nupstream `mcp` SDK change, or already mitigated — but are documented here\nso they're visibly known rather than silently absent.\n\n- **OAuth authorization-response `iss` param (RFC 9207) not sent.** The\n  spec hardens the OAuth flow against mix-up attacks by having the\n  authorization server include an `iss` parameter in the redirect back to\n  the client ([RFC 9207 §2.4](https://www.rfc-editor.org/rfc/rfc9207.html#section-2.4)),\n  which spec-compliant clients then validate. This server's `/login` flow\n  builds its final redirect by hand in `complete_login()`\n  ([`app/oauth_provider.py`](app/oauth_provider.py)) rather than through\n  the `mcp` SDK's built-in authorize handler, and currently omits `iss`.\n  Harmless today: the installed `mcp` SDK (`mcp>=2.0.0,<3` in\n  `pyproject.toml`) never advertises\n  `authorization_response_iss_parameter_supported` in this server's OAuth\n  metadata, so no compliant client requires it yet. Revisit if a future\n  SDK version turns that advertisement on by default.\n\n- **Dynamic Client Registration (RFC 7591) instead of CIMD.** The same\n  spec update formally deprecates Dynamic Client Registration in favor of\n  Client ID Metadata Documents (CIMD), though DCR remains functional for\n  backward compatibility. This server's client auto-provisioning\n  (`_FlexibleClientInformation`/`register_client`/`get_client` in\n  [`app/oauth_provider.py`](app/oauth_provider.py)) is built on DCR —\n  needed because some MCP clients (observed: Claude Desktop/claude.ai)\n  skip registration and send `/authorize` an unregistered `client_id`\n  directly (see that class's docstring). No action needed while the\n  installed SDK keeps DCR working without warning; will need a\n  CIMD-based replacement if/when that changes.\n\n- **`rename_tag` can block on large libraries** — tracked as\n  [#6](https://github.com/herbertkokholm/cite-caddy/issues/6); candidate\n  for the spec's new `tasks` extension once the installed SDK exposes\n  one.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) for the threat model and how to report a\nvulnerability.\n\n## Privacy\n\nSee [Privacy Policy](https://herbertkokholm.dk/cite-caddy/privacy.html) for\nwhat the server stores when you sign in at `/login` (Zotero Library\nID/Type/API key), how it's protected, and how to have it deleted.\n\n## License\n\n[MIT](LICENSE)\n",
  "bytes": 19421,
  "sha": "6cfe869de9bd396d83a6f05ec4e053ae98e2d539774dd4f888ad2f2c71e68042",
  "repo_slug": "herbertkokholm/cite-caddy",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_dk_herbertkokholm_citecaddy_cite_caddy_38693647/readme"
}