Remote OAuth
Add Zoteus as a claude.ai custom connector — run your own OAuth 2.1 + PKCE authorization server in front of the Streamable HTTP /mcp endpoint.
claude.ai connects to remote MCP servers from the cloud, so a connector must be reachable at a public HTTPS URL and protected by OAuth 2.1 + PKCE (a static API key or Authorization header cannot be entered in the connector UI). Since v0.9.0, Zoteus can be that connector: it runs its own OAuth 2.1 authorization server in front of the Streamable HTTP /mcp endpoint.
How it works (single-tenant gating)
Zotero's own API uses OAuth 1.0a, which can't be proxied to satisfy claude.ai's OAuth 2.1. So Zoteus acts as its own OAuth 2.1 authorization server and gates who may connect — it does not federate Zotero accounts:
- The deployed instance holds one operator
ZOTERO_API_KEY(single tenant). - claude.ai self-registers via Dynamic Client Registration (RFC 7591), runs the authorization-code + PKCE (S256) flow, and exchanges the code for a short-lived opaque bearer token.
- Issuance is gated by a single operator passcode (
ZOTEUS_OAUTH_PASSCODE): during consent the browser shows a one-field passcode page; only a correct passcode mints an authorization code. /mcpthen requires a valid bearer token (requireBearerAuth).
Standards served automatically by the MCP SDK auth helpers:
| Endpoint | Spec |
|---|---|
/.well-known/oauth-authorization-server | RFC 8414 (AS metadata) |
/.well-known/oauth-protected-resource/mcp | RFC 9728 (protected-resource metadata) |
/register | RFC 7591 (Dynamic Client Registration) |
/authorize → consent → /token | OAuth 2.1 auth-code + PKCE S256 |
/revoke | RFC 7009 |
Discovery is driven by the WWW-Authenticate: Bearer ..., resource_metadata="…" header returned from an unauthenticated /mcp request, so claude.ai never has to guess paths.
Configuration
| Variable | Required | Purpose |
|---|---|---|
ZOTERO_API_KEY | yes | The operator's Zotero key (the library every connected client uses). |
ZOTEUS_OAUTH_ENABLED | yes | Set true to turn on the OAuth-protected remote. |
ZOTEUS_PUBLIC_URL | yes | Public HTTPS origin claude.ai reaches, e.g. https://zoteus.example.com (no trailing slash). Becomes the OAuth issuer; must be HTTPS in production. |
ZOTEUS_OAUTH_PASSCODE | yes | Consent passcode, ≥ 12 chars. Generate with openssl rand -base64 24. |
ZOTEUS_READ_ONLY | recommended | true exposes only non-mutating tools — strongly recommended for a public connector. |
ZOTEUS_OAUTH_ACCESS_TTL | no | Access-token lifetime in seconds (default 3600). |
ZOTEUS_OAUTH_REFRESH_TTL | no | Refresh-token lifetime in seconds (default 2592000, 30 days). |
ZOTEUS_ALLOWED_HOSTS | no | Comma-separated extra Host values accepted by DNS-rebinding protection, merged with the ZOTEUS_PUBLIC_URL host. Use only if your proxy rewrites Host (see below). |
When OAuth is enabled, zoteus --http binds 0.0.0.0 (so a reverse proxy/tunnel can reach it) and enables DNS-rebinding protection with allowedHosts = [<public host>, …ZOTEUS_ALLOWED_HOSTS].
Safety guard: without OAuth, Zoteus refuses to bind a non-loopback host (it would be an open, unauthenticated relay to your library). Override only for a deliberate trusted-network setup with
ZOTEUS_ALLOW_INSECURE_HTTP=true.
Run it
ZOTERO_API_KEY=zzz \
ZOTEUS_OAUTH_ENABLED=true \
ZOTEUS_PUBLIC_URL=https://zoteus.example.com \
ZOTEUS_OAUTH_PASSCODE="$(openssl rand -base64 24)" \
ZOTEUS_READ_ONLY=true \
node dist/index.js --http --port 3939 --host 0.0.0.0Or with Docker (see Dockerfile):
docker build -t zoteus:0.9.0 .
docker run -p 3939:3939 \
-e ZOTERO_API_KEY=zzz \
-e ZOTEUS_OAUTH_ENABLED=true \
-e ZOTEUS_PUBLIC_URL=https://zoteus.example.com \
-e ZOTEUS_OAUTH_PASSCODE="$(openssl rand -base64 24)" \
-e ZOTEUS_READ_ONLY=true \
zoteus:0.9.0Put it behind HTTPS
TLS is required. Any of these works; the key requirement is the proxy must forward the public Host header verbatim (DNS-rebinding protection does an exact Host match, including port):
- VPS + Caddy —
zoteus.example.com { reverse_proxy 127.0.0.1:3939 }(Caddy auto-provisions TLS and preservesHost). - Cloudflare named tunnel —
cloudflared tunnel runmapped to a stable hostname (https://zoteus.example.com). Use a named tunnel so the hostname is stable; a quick tunnel's random hostname changes each run and won't matchZOTEUS_PUBLIC_URL. - Fly.io / Render / Railway — deploy the Docker image; set the env vars above; set
ZOTEUS_PUBLIC_URLto the platform-assigned HTTPS hostname. These terminate TLS and forward the publicHost.
If your proxy rewrites Host to an internal value (causing every /mcp request to 403), add the forwarded value to ZOTEUS_ALLOWED_HOSTS.
Connect from claude.ai
- Settings → Connectors → Add custom connector.
- URL:
https://<your-host>/mcp. (No client id/secret needed — claude.ai self-registers via DCR.) - Click Connect. claude.ai opens the Zoteus consent page.
- Enter your
ZOTEUS_OAUTH_PASSCODEand authorize. - The tool list loads; try a read (e.g.
zotero_whoamiorzotero_search_items).
Connect from Claude Code (remote)
The same OAuth remote also works from the Claude Code CLI — useful for testing the connector or using a hosted instance from the terminal. Claude Code runs the OAuth flow itself (DCR + PKCE with an RFC 8252 loopback redirect):
claude mcp add --transport http zoteus https://<your-host>/mcpThen, inside Claude Code:
- Run
/mcp→ select zoteus → Authenticate (a browser opens). - Complete the Zoteus consent page (enter the
ZOTEUS_OAUTH_PASSCODE) → it redirects to alocalhostport Claude Code owns and/mcpflips to connected. - Use it, e.g. "use zoteus — who am I signed in as?".
Notes:
- Names must be unique. If
zoteusalready exists (e.g. a leftover stdio entry), runclaude mcp remove zoteusfirst. - Add
-s userto make the server available in every project (default scope is the current directory). - The callback is an ephemeral
http://localhost:<port>/callbackthat Claude Code owns; Zoteus accepts loopback redirects port-agnostically, so no--callback-portis needed. - Manage with
claude mcp list/claude mcp get zoteus/claude mcp remove zoteus.
Multi-tenant: per-user Zotero accounts
By default Zoteus runs single-tenant (ZOTEUS_OAUTH_MODE=passcode): every connected
client uses the one operator ZOTERO_API_KEY, gated by a shared passcode.
Set ZOTEUS_OAUTH_MODE=zotero to make Zoteus multi-tenant — each user who adds the
connector logs into their own Zotero account, and every call runs against that user's
library. Zoteus stays its own OAuth 2.1 server for claude.ai; during consent it performs
Zotero's OAuth 1.0a on the user's behalf and binds the resulting per-user Zotero key to the
issued bearer token. No ZOTERO_API_KEY and no ZOTEUS_OAUTH_PASSCODE are needed in this mode.
Setup
- Register a Zotero app at https://www.zotero.org/oauth/apps. Set the callback to
https://<your-host>/oauth/zotero/callback. Note the Client Key and Client Secret. - Configure:
ZOTEUS_OAUTH_ENABLED=true ZOTEUS_OAUTH_MODE=zotero ZOTEUS_PUBLIC_URL=https://<your-host> ZOTERO_OAUTH_CLIENT_KEY=<client key> ZOTERO_OAUTH_CLIENT_SECRET=<client secret> ZOTEUS_OAUTH_STORE=file ZOTEUS_OAUTH_TOKEN_SECRET="$(openssl rand -base64 32)" ZOTEUS_READ_ONLY=true ZOTEUS_DATA_DIR=/data # mount a volume so the store + per-user indexes persist - Deploy behind HTTPS (Fly/Render/Railway/VPS+Caddy/named cloudflared) exactly as for
single-tenant, mounting a volume at
ZOTEUS_DATA_DIR.
Notes
- Encryption at rest: stored Zotero keys are AES-256-GCM encrypted with
ZOTEUS_OAUTH_TOKEN_SECRET. Losing or rotating the secret invalidates the store (users simply re-authorize). The store file lives underZOTEUS_DATA_DIR(git-ignored). - Per-user index:
zotero_indexbuilds a separate semantic index per user (search-index-<userID>.json); tenants never share an index. - Single instance: the file store is local; run one instance (no shared-replica state).
- Read-only recommended: keep
ZOTEUS_READ_ONLY=trueso the connector requests read-only Zotero permissions.
Security notes
- The passcode is the trust boundary. Use a high-entropy value and rotate it (restart with a new
ZOTEUS_OAUTH_PASSCODE)./consentis rate-limited and locks a pending authorization after repeated wrong attempts. - State persistence. By default (
ZOTEUS_OAUTH_STORE=memory) registered clients and tokens live in memory only — they do not survive a restart. SetZOTEUS_OAUTH_STORE=file(withZOTEUS_OAUTH_TOKEN_SECRET) to persist clients, tokens, and per-user Zotero keys across restarts, encrypted at rest under the data dir. Either way, state is local to one instance (no shared-replica store). Short-lived pending consents and auth codes always stay in memory; a mid-flow restart just re-prompts. - Per-session transports. Each MCP session gets its own Streamable HTTP transport (keyed by
Mcp-Session-Id), sharing one Zotero context — so multiple/reconnecting claude.ai sessions are isolated and do not collide. - Dynamic Client Registration. Claude registers a fresh public client per connection; Zoteus caps the in-memory client store (FIFO) and sweeps expired state.
- Token lifetime. Refresh tokens are rotated on each use (the old one is invalidated in the same response); access tokens remain valid until their TTL even after rotation. Shorten
ZOTEUS_OAUTH_ACCESS_TTLfor tighter revocation, or use/revoke. - Proxy must forward
Host. DNS-rebinding protection matches theHostheader exactly; your TLS proxy/tunnel must forward the public host verbatim (add extras viaZOTEUS_ALLOWED_HOSTSif not). - Prefer
ZOTEUS_READ_ONLY=trueand keepZOTEUS_ALLOW_DELETE=falsefor public connectors.