Zoteus

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.
  • /mcp then requires a valid bearer token (requireBearerAuth).

Standards served automatically by the MCP SDK auth helpers:

EndpointSpec
/.well-known/oauth-authorization-serverRFC 8414 (AS metadata)
/.well-known/oauth-protected-resource/mcpRFC 9728 (protected-resource metadata)
/registerRFC 7591 (Dynamic Client Registration)
/authorize → consent → /tokenOAuth 2.1 auth-code + PKCE S256
/revokeRFC 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

VariableRequiredPurpose
ZOTERO_API_KEYyesThe operator's Zotero key (the library every connected client uses).
ZOTEUS_OAUTH_ENABLEDyesSet true to turn on the OAuth-protected remote.
ZOTEUS_PUBLIC_URLyesPublic 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_PASSCODEyesConsent passcode, ≥ 12 chars. Generate with openssl rand -base64 24.
ZOTEUS_READ_ONLYrecommendedtrue exposes only non-mutating tools — strongly recommended for a public connector.
ZOTEUS_OAUTH_ACCESS_TTLnoAccess-token lifetime in seconds (default 3600).
ZOTEUS_OAUTH_REFRESH_TTLnoRefresh-token lifetime in seconds (default 2592000, 30 days).
ZOTEUS_ALLOWED_HOSTSnoComma-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.0

Or 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.0

Put 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 + Caddyzoteus.example.com { reverse_proxy 127.0.0.1:3939 } (Caddy auto-provisions TLS and preserves Host).
  • Cloudflare named tunnelcloudflared tunnel run mapped 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 match ZOTEUS_PUBLIC_URL.
  • Fly.io / Render / Railway — deploy the Docker image; set the env vars above; set ZOTEUS_PUBLIC_URL to the platform-assigned HTTPS hostname. These terminate TLS and forward the public Host.

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

  1. Settings → Connectors → Add custom connector.
  2. URL: https://<your-host>/mcp. (No client id/secret needed — claude.ai self-registers via DCR.)
  3. Click Connect. claude.ai opens the Zoteus consent page.
  4. Enter your ZOTEUS_OAUTH_PASSCODE and authorize.
  5. The tool list loads; try a read (e.g. zotero_whoami or zotero_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>/mcp

Then, inside Claude Code:

  1. Run /mcp → select zoteusAuthenticate (a browser opens).
  2. Complete the Zoteus consent page (enter the ZOTEUS_OAUTH_PASSCODE) → it redirects to a localhost port Claude Code owns and /mcp flips to connected.
  3. Use it, e.g. "use zoteus — who am I signed in as?".

Notes:

  • Names must be unique. If zoteus already exists (e.g. a leftover stdio entry), run claude mcp remove zoteus first.
  • Add -s user to make the server available in every project (default scope is the current directory).
  • The callback is an ephemeral http://localhost:<port>/callback that Claude Code owns; Zoteus accepts loopback redirects port-agnostically, so no --callback-port is 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

  1. 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.
  2. 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
  3. 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 under ZOTEUS_DATA_DIR (git-ignored).
  • Per-user index: zotero_index builds 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=true so 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). /consent is 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. Set ZOTEUS_OAUTH_STORE=file (with ZOTEUS_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_TTL for tighter revocation, or use /revoke.
  • Proxy must forward Host. DNS-rebinding protection matches the Host header exactly; your TLS proxy/tunnel must forward the public host verbatim (add extras via ZOTEUS_ALLOWED_HOSTS if not).
  • Prefer ZOTEUS_READ_ONLY=true and keep ZOTEUS_ALLOW_DELETE=false for public connectors.

On this page