{"id":"7c14db6c-4a6a-4725-a19c-97a213be4918","slug":"linxiv-web","kind":"mcp","name":"linXiv-web","summary":"Unofficial linXiv component for seflhosting and a web UI","owner":"ToledoEM","repoUrl":"https://github.com/ToledoEM/linXiv-web","license":"Apache-2.0","licenseTier":"permissive","trust":"community","stars":1,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-10-07T14:55:56.000Z","addedAt":"2026-10-21T05:15:54.214Z","logoUrl":"https://avatars.githubusercontent.com/u/8144940?u=d91d7abfd185c8260f3182906811fb5d6fdedb33&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["files-documents","cloud-infra","design-media"],"tags":["file-management","paper-reading","reference-manager","selfhosted","local-first","research","research-paper","linxiv"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T06:41:26.392Z","sample":false,"url":"https://conduits.dev/tools/linxiv-web","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# linXiv-web\n\n[![CI](https://github.com/ToledoEM/linXiv-web/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ToledoEM/linXiv-web/actions/workflows/ci.yml)\n[![CodeQL](https://github.com/ToledoEM/linXiv-web/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/ToledoEM/linXiv-web/actions/workflows/codeql.yml)\n[![Security](https://github.com/ToledoEM/linXiv-web/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/ToledoEM/linXiv-web/actions/workflows/security.yml)\n[![Release](https://img.shields.io/github/v/release/ToledoEM/linXiv-web)](https://github.com/ToledoEM/linXiv-web/releases)\n[![License](https://img.shields.io/github/license/ToledoEM/linXiv-web)](LICENSE)\n[![Image](https://img.shields.io/badge/ghcr.io-linxiv--web-blue?logo=docker)](https://github.com/ToledoEM/linXiv-web/pkgs/container/linxiv-web)\n[![linXiv](https://img.shields.io/badge/linXiv-v0.7.1-informational)](https://github.com/linxiv-dev/linXiv/releases/tag/v0.7.1)\n\nlinXiv-web is a browser UI for [linXiv](https://github.com/linxiv-dev/linXiv). You can search arXiv, OpenAlex and Crossref, add papers to a library, tag them, organize them into projects and reading lists, read and highlight their PDFs, and take notes.\n\nIt aligns with the [linxiv-dev](https://github.com/linxiv-dev) organization but has not been adopted by linXiv maintainers, and remains unofficial.\n\n## Live demo\n\nTry it at <https://linxiv-web.onrender.com> with the password `trymelinxiv-web`.\n\n## Features\n\n- **Library:** sortable table (click any column header), one search box over title, abstract, authors, ids, tags, indexed full text and notes, PDF filters, batch metadata editing.\n- **Papers:** metadata editing with lookup and side-by-side compare against Crossref, arXiv, OpenAlex, PubMed and bioRxiv/medRxiv; versions; tags; notes; PDF reader with find and highlights; arXiv TeX source indexing for full-text search.\n- **Projects and reading lists:** projects with export (BibTeX, Obsidian, Zotero, `.lxproj`) and `.lxproj` import; reading lists on their own page.\n- **Discovery:** search tabs (arXiv, OpenAlex, Crossref, local) with an advanced arXiv query builder, add papers by arXiv id, DOI or PDF link, RSS/Atom home feed with filter rules, arXiv new-version monitor.\n- **Authors and graph:** author pages, edit and merge, ORCID backfill, knowledge graph of papers, authors and tags.\n- **Maintenance:** trash, backup / import / restore of the database, settings export / import, PDF folder layouts (by year and/or journal), background full-text indexer. Admins get maintenance operations that run on demand or nightly over every library: find duplicate papers by PDF checksum and merge them, complete metadata of new papers by DOI or arXiv id, check PDF files, keep database backups on the server, remove authors without papers, empty old trash.\n- **AI (opt-in):** tag suggestions, paper summaries saved as notes, metadata cleanup.\n- **MCP (opt-in):** linXiv's own MCP tools over HTTP at `/mcp`, for any AI tool that accept MCP protocol.\n- Themes (light/dark), zoom and density, keyboard shortcuts, sidebar page toggles.\n\n## How it works\n\nlinXiv-web reaches linXiv through the `linxiv` CLI, which prints JSON for every command. A FastAPI server runs `linxiv <command>` as a subprocess, passing arguments as a list without a shell, and serves a React single-page app.\n\n```\nbrowser ──HTTP──▶ FastAPI (/api/*, static SPA) ──subprocess──▶ linxiv CLI ──▶ papers.db + pdfs/\nMCP client ──HTTP /mcp──▶ FastAPI ──stdio──▶ linxiv-mcp ──▶ papers.db + pdfs/\n```\n\nLibrary reads and writes go through the CLI. Two exceptions, both where the CLI has no command: PDF folder layouts move files and update their stored path in `papers.db` directly (under the CLI lock), and the AI summary reads text from a saved PDF.\n\nThe design is to be local-only, but linXiv-web reach out to external services itself **only** in these cases:\n\n- downloads a PDF link pasted into Add Paper before importing it (public hosts only, size-capped)\n- metadata lookup queries PubMed through NCBI E-utilities (`eutils.ncbi.nlm.nih.gov`), bioRxiv/medRxiv through `api.biorxiv.org` or others\n- when AI features are switched on, AI actions send the paper's title, abstract (and, if you choose, PDF text) to the provider you configured (Unless is local). Nothing is sent until you click an AI button.\n\nA linXiv library allows one writing process per data dir, so the server runs CLI calls one at a time behind a lock. Work that needs many calls, such as indexing the TeX source of every saved arXiv paper, runs as a background job that makes one call per paper, so other requests get the lock between them. Jobs live in memory: restarting the server stops a running job and forgets finished ones. \n\n`linxiv` always writes PDFs to `<data dir>/pdfs`. The container turns that path into a symlink to `/pdfs`, which lets the database stay on local disk while the PDFs sit on other storage.\n\nLike `linxiv-headless`, the server refuses to start if it binds to a non-loopback address without a password.\n\n## Compatibility\n\n| linXiv-web | linXiv CLI / MCP |\n|---|---|\n| 0.1.x | v0.7.1 |\n\nThe image builds `linxiv` and `linxiv-mcp` from upstream source at the `LINXIV_REF` build arg (default `v0.7.1`).\n\n## Resource usage\n\nMeasured on a home server (Intel i7-7560U, 4 threads, 16 GB RAM), with a library of ~6,000 papers, ~6,000 PDFs and ~2000 tags:\n\n| | |\n|---|---|\n| Image size | 357 MB on disk (91 MB compressed) |\n| Memory, idle | 155–185 MiB |\n| Memory, peak | 302 MiB |\n| CPU, idle | under 1% |\n| Data dir (`papers.db`, logs, state) | 16 MB |\n| PDFs | 12 GB (on a separate USB disk) |\n\nNo CPU or memory limits were set.\n\n## Run (Docker)\n\nTo self-host from the prebuilt image (first-run setup, reverse proxy, backups, updates), see [docs/self-hosting.md](docs/self-hosting.md). To import a folder of PDFs already on the server without the web UI, see [docs/bulk-import.md](docs/bulk-import.md). The steps below build from this checkout.\n\n```sh\ncp .env.example .env\nmkdir -p .dev/data .dev/pdfs\n\n# Generate a password hash and paste it into .env (keep the single quotes)\ndocker compose build\ndocker compose run --rm --no-deps -e PASSWORD='your-password' linxiv-web python -m app hash-password\n\ndocker compose up -d\n```\n\nOpen http://127.0.0.1:8086 and log in. After an update, rebuild and recreate the container (`docker compose build && docker compose up -d --force-recreate`), then reload the page with Cmd/Ctrl+Shift+R.\n\nTo keep the library elsewhere, set `LINXIV_WEB_DATA_HOST` and `LINXIV_WEB_PDF_HOST` in `.env`. Both directories must already exist. Keep the database directory on local disk, because SQLite over network or exFAT storage is unreliable.\n\n### Run with your own docker-compose.yml and .env\n\nTo run linXiv-web on another machine without cloning the repo, make a folder with two files. Compose builds the image straight from the git repository: replace `$PLACEHOLDERFUTUREURL` with the repository's git URL. \n\n```sh\nmkdir -p linxiv-web/data linxiv-web/pdfs\ncd linxiv-web\n```\n\n`docker-compose.yml`:\n\n```yaml\nservices:\n  linxiv-web:\n    build:\n      # Replace with the repository's git URL\n      context: $PLACEHOLDERFUTUREURL#main\n      args:\n        LINXIV_REF: ${LINXIV_REF:-v0.7.1}\n    image: linxiv-web:local\n    container_name: linxiv-web\n    restart: unless-stopped\n    env_file: .env\n    ports:\n      # 127.0.0.1 = this machine only. Use 0.0.0.0 to reach it from the LAN.\n      - \"${LINXIV_WEB_BIND:-127.0.0.1}:${LINXIV_WEB_PUBLISH_PORT:-8086}:8000\"\n    volumes:\n      - type: bind\n        source: ${LINXIV_WEB_DATA_HOST:-./data}\n        target: /data\n        bind:\n          create_host_path: false\n      - type: bind\n        source: ${LINXIV_WEB_PDF_HOST:-./pdfs}\n        target: /pdfs\n        bind:\n          create_host_path: false\n    healthcheck:\n      test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\"]\n      interval: 30s\n      timeout: 5s\n      retries: 3\n```\n\n`.env` (keep the single quotes around the hash; it contains `$`):\n\n```sh\n# Login password: argon2 hash (see the command below)\nLINXIV_WEB_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$REPLACE$ME'\n\n# Optional session signing key (any long random string). Unset: one is\n# generated and kept in the data dir. Changing it logs everyone out.\n# LINXIV_WEB_SECRET_KEY=\n\n# Where the port is published (docker-compose.yml above)\nLINXIV_WEB_BIND=127.0.0.1\nLINXIV_WEB_PUBLISH_PORT=8086\n\n# Library location on this machine (must exist; keep data on local disk)\nLINXIV_WEB_DATA_HOST=./data\nLINXIV_WEB_PDF_HOST=./pdfs\n\n# Behind an HTTPS reverse proxy\n# LINXIV_WEB_COOKIE_SECURE=true\n\n# Optional: contact addresses and API keys. They can also be set in Settings;\n# values here take precedence.\n# CROSSREF_MAILTO=you@example.org\n# NCBI_API_KEY=\n# GEMINI_API_KEY=\n# OPENAI_API_KEY=\n# ANTHROPIC_API_KEY=\n# GENAI_API_KEY_TAG_GEN=\n```\n\nBuild, set the password, start:\n\n```sh\ndocker compose build\ndocker compose run --rm --no-deps -e PASSWORD='your-password' linxiv-web python -m app hash-password\n# paste the printed hash into .env as LINXIV_WEB_PASSWORD_HASH, then:\ndocker compose up -d\n```\n\nOpen http://127.0.0.1:8086 (or `http://<machine-ip>:8086` with `LINXIV_WEB_BIND=0.0.0.0`). \nTo update later: `docker compose build --no-cache && docker compose up -d`.\n\n### Configuration\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `LINXIV_WEB_PASSWORD_HASH` | none | argon2 hash of the login password (`python -m app hash-password`). Without it (or `LINXIV_WEB_PASSWORD`), first-run setup in the browser creates the password, stored in `<data dir>/linxiv-web-auth.json` |\n| `LINXIV_WEB_PASSWORD` | none | Plain password, hashed in memory at startup (dev only) |\n| `LINXIV_WEB_SECRET_KEY` | generated, kept in `<data dir>/linxiv-web-state.db` | Session signing key. Changing it logs everyone out |\n| `LINXIV_WEB_COOKIE_SECURE` | `false` | Set to `true` behind HTTPS |\n| `LINXIV_WEB_HSTS` | `false` | Send `Strict-Transport-Security` (1 year). Only behind HTTPS: browsers then refuse plain HTTP to this host |\n| `LINXIV_WEB_REMEMBER_DAYS` | `90` | Login lifetime when \"Remember me\" is ticked (otherwise the login lasts until the browser closes, at most 14 days) |\n| `LINXIV_WEB_DATA_DIR` | `/data` | linXiv data dir (`papers.db`). Passed to the CLI and MCP server as `LINXIV_DATA_DIR` |\n| `LINXIV_WEB_PDF_DIR` | `/pdfs` | PDF directory, symlinked from `<data dir>/pdfs` |\n| `LINXIV_WEB_HOST` / `LINXIV_WEB_PORT` | `0.0.0.0` / `8000` in the image | Listen address |\n| `LINXIV_WEB_ALLOW_EXTERNAL` | `false` | Serve clients with public IP addresses. Off: only loopback, private LAN and link-local clients get through (others get 403) |\n| `LINXIV_WEB_TRUSTED_PROXIES` | none | Comma-separated IPs/CIDRs of reverse proxies whose `X-Forwarded-For` is believed (read right to left). Unlisted proxies count as the client |\n| `LINXIV_WEB_MAX_UPLOAD_MB` | `200` | Upload size cap |\n| `LINXIV_WEB_TIMEOUT_LONG` / `_SHORT` | `120` / `30` | CLI timeout in seconds (search, fetch, download and import use the long one) |\n| `LINXIV_BIN` / `LINXIV_MCP_BIN` | `linxiv` / `linxiv-mcp` | CLI and MCP server binaries |\n| `ARXIV_MAILTO`, `CROSSREF_MAILTO`, `OPENALEX_MAILTO` | none | Contact addresses sent to those services; override the values set in Settings |\n| `NCBI_API_KEY` | none | PubMed key; overrides the one set in Settings |\n| `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GENAI_API_KEY_TAG_GEN` | none | AI provider keys (the last one is for the OpenAI-compatible endpoint); override the ones set in Settings |\n| `LINXIV_WEB_AI_ALLOWED_HOSTS` | none | Comma-separated host names the OpenAI-compatible base URL may use. Unset: any host except cloud metadata addresses (`169.254.0.0/16`, `fd00:ec2::254`, `100.100.100.200`), which are always refused |\n\nKeys entered in Settings are stored in the linXiv settings store and are never sent back to the browser (only their last 4 characters).\n\n## AI features (Off by default)\n\nIn **Settings → AI**: turn on, pick a API provider (Gemini, OpenAI, Anthropic, OpenRouter, or an OpenAI-compatible base URL such as `http://localhost:1234/v1` for LM Studio), paste a key (optional for local servers), Save, then **Load models**, pick one, Save, and **Test connection**.\n\n| Action | Where | What the model gets | Result |\n|---|---|---|---|\n| AI Tags | paper page, Library batch panel (\"AI tags\") | title, categories, abstract, current tags, your existing tag vocabulary | up to 8 tags to tick and add |\n| AI summary | paper page | \"Abstract only\", or \"Abstract + PDF text\" (first 40 pages, 60k characters) | summary + key points, saved as a note on request |\n| AI cleanup | Edit metadata and Library batch metadata, source \"AI cleanup\" | title, authors, journal | a candidate shown as a field diff; nothing is saved until you approve |\n\nEvery request carries a JSON Schema and a valid example reply (shown in Settings → AI). OpenAI and OpenAI-compatible servers get structured output (`response_format: json_schema`), which also keeps \"thinking\" models such as Qwen from spending the whole token budget on reasoning; a server that rejects it is retried with the schema in the prompt only. Anthropic uses a forced tool call. Requests time out after 90 s (300 s for OpenAI-compatible servers, since local models read long prompts slowly).\n\n*Computing time*: On Mac Book M2 16GB Ram, Tag generation with LM-studio gemma-4-e2b and thinking disabled, takes ~2 seconds per request.\n\n## MCP server (Off by default)\n\nAlso is provided an MCP server that any agent to expose its tools locally. linXiv-web builds upstream's `linxiv-mcp` and serves its tools over MCP Streamable HTTP at `/mcp`. Each MCP session gets its own `linxiv-mcp` process (at most 4 sessions, closed after 30 minutes idle).\n\nIn **Settings → MCP server**: tick the switch, choose which tools the agent gets, Save, then **Create token**. The token is shown once (only its SHA-256 is stored); replacing or revoking it disconnects open sessions. The card shows the commands with the token filled in:\n\n```sh\n# Claude Code\nclaude mcp add --transport http linxiv http://127.0.0.1:8086/mcp --header \"Authorization: Bearer <token>\"\n\n# Codex\nexport LINXIV_MCP_TOKEN=<token>\ncodex mcp add linxiv --url http://127.0.0.1:8086/mcp --bearer-token-env-var LINXIV_MCP_TOKEN\n```\n\nOpenCode takes a remote server entry in `opencode.json` with `\"headers\": { \"Authorization\": \"Bearer {env:LINXIV_MCP_TOKEN}\" }`.\n\n| Profile | Tools | Adds |\n|---|---|---|\n| Read only (default) | 32 | search, get, list, refs, stats |\n| Read + edit library | 68 | papers, notes, tags, projects, highlights; soft deletes (to the trash) |\n| Full | 75 | hard deletes, merging papers or authors, metadata overwrite |\n\nNever exposed: `get_settings` / `update_setting` (the settings store holds API keys), backup and restore, and the import/export tools that read or write server file paths. `download_pdf` with a caller-chosen `url` is refused; the paper's stored link is used. Tools added upstream stay hidden until they are classified.\n\n`/mcp` needs the bearer token, answers 404 while switched off, and refuses requests that carry another site's `Origin`.\n\nEvery tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`) derived from its profile tier, so clients can auto-allow read-only tools and confirm destructive ones. Full guide with every tool, client setup and troubleshooting: [docs/mcp.md](docs/mcp.md).\n\n## API\n\nAll routes except `/api/health`, `/api/login`, `/api/me` and `/mcp` need a session when a password is set. Write requests also need the `X-CSRF-Token` header, which echoes the `lxw_csrf` cookie.\n\n| Route | linxiv command / behaviour |\n|---|---|\n| `POST /api/password` `{current, new, remember?}` | Change the stored admin password (409 when it comes from `LINXIV_WEB_PASSWORD(_HASH)`); logs out every other session |\n| `POST /api/sessions/revoke-others` | Log out every session except the caller's; returns `{revoked}` |\n| `GET /api/activity?limit`, `GET /api/activity/export` | Activity log, newest first / as CSV: every POST/PUT/PATCH/DELETE under `/api/` with time, client address, route, path parameters, status (Settings saves: key names only). Kept in `<data dir>/logs/activity.jsonl`, 5 MiB + one rotated file |\n| `DELETE /api/activity` `{confirm: \"delete activity log\"}` | Clear the activity log (the clear is recorded) |\n| `GET /api/system/diagnostics` | Markdown report for bug reports: versions, configuration, library counts, disk, health, running jobs, DNS + TCP 443 reachability of outside services. Secrets and addresses that could locate the server only as set / not set |\n| `GET /api/search?q&source=arxiv\\|openalex\\|crossref&max` | `search` |\n| `POST /api/papers/{id}/fetch?source=` | `fetch`, then `paper get` |\n| `GET /api/papers?q&tag&pdf=has\\|none&fulltext&limit&offset&category&sort&dir` | `list` filtered server-side; a `q` of 3+ characters also runs `paper search` (TeX full text and notes), hits flagged `deep_match`; `fulltext=true` returns only the `paper search` hits. Each paper also gets `pdf_file` (its PDF's path inside the PDF folder) and `title_suspect` (the title looks like junk read from the PDF, or unlike the file name) |\n| `GET /api/papers/{id}`, `GET /api/papers/{id}/versions` | `paper get` (plus `pdf_file` and `title_suspect`), `paper versions` |\n| `DELETE /api/papers/{id}` | `paper delete` (soft delete, goes to trash) |\n| `GET /api/papers/{id}/pdf` | `pdf path`, then serves the file (only from inside the data or PDF dir) |\n| `POST /api/papers/{id}/pdf` `{url?, version?}` | `pdf download` (for arXiv papers the URL is derived) |\n| `DELETE /api/papers/{id}/pdf` | `pdf delete` |\n| `PUT /api/papers/{id}/metadata` `{title, authors[], published, summary, category?, doi?, url?}` | `paper get`, then `paper repair` with the full record and the paper's current tags (repair clears any field it isn't given) |\n| `GET /api/papers/{id}/lookup?source=crossref\\|arxiv\\|openalex\\|pubmed\\|biorxiv&by=auto\\|filename` | Candidate metadata: `doi resolve` (by DOI), `search` (by arXiv id or title), NCBI E-utilities for PubMed (by DOI, then title), api.biorxiv.org (by DOI). `by=filename` searches by the title in the PDF's file name, ignoring the stored DOI, and adds a last candidate built from the file name and its year folder |\n| `GET/POST/DELETE /api/papers/{id}/tags` | `tag list` / `tag add` / `tag remove` |\n| `GET /api/pdfs`, `GET /api/pdfs/storage` | `pdf list`, `pdf storage` (recounted including subfolders) |\n| `POST /api/pdfs/import` (multipart `file`) | `pdf import` |\n| `GET /api/pdfs/layout/preview?layout=`, `POST /api/jobs/pdf-layout` | PDF folder layout (`none`, `year`, `journal`, `year/journal`, `journal/year`): preview, then a job that moves files and updates their stored path |\n| `POST /api/import/bibtex`, `POST /api/import/zotero` (multipart `file`) | `bibtex import`, `zotero import` (upsert: existing entries are updated) |\n| `POST /api/add` `{input}` | arXiv id/link: `fetch`; DOI or doi.org link: `doi save`; other http(s) link: server downloads the PDF (public hosts only, size-capped), then `pdf import` |\n| `GET /api/stats`, `/api/categories`, `/api/tags` | `stats`, `categories`, `tag list-all` |\n| `GET /api/authors?hide_single`, `GET/PATCH/DELETE /api/authors/{id}` | `author list`, `author get`, `author update`, `author delete` (409 while linked to papers) |\n| `GET /api/authors/{id}/merge-candidates`, `POST /api/authors/{id}/merge` | `author merge-candidates` (plus same-name hints), `author merge` |\n| `POST /api/orcid/backfill` `{limit?}` | `orcid backfill` (1 to 100; 409 while one runs) |\n| `GET /api/projects?status=active\\|archived`, `POST /api/projects`, `PATCH /api/projects/{id}` | `project list`, `project create`, `project update` |\n| `GET /api/projects/{id}`, `GET /api/projects/{id}/papers` | `project get`; papers = `project get` + `list`, filtered to members in project order |\n| `POST /api/projects/{id}/papers` (`{\"source_ids\": [...]}`), `DELETE /api/projects/{id}/papers/{source_id}` | `project add-papers` (200 with `added`/`failed`), `project remove-paper` |\n| `GET /api/projects/{id}/export?format=lxproj\\|bibtex\\|obsidian\\|zotero&pdfs` | `project export [--pdfs]`, `export-bibtex`, `export-obsidian`, `export-zotero`; written to a temp dir, sent as a download, then deleted |\n| `POST /api/projects/import/preview`, `POST /api/projects/import` (multipart `file`, `on_conflict=merge\\|overwrite`) | `project import --preview`, `project import` (`.lxproj`, zip checked before the CLI sees it) |\n| `POST /api/projects/{id}/archive`, `POST /api/projects/{id}/restore`, `DELETE /api/projects/{id}` | `project archive`, `project restore`, `project delete` (soft delete, goes to trash) |\n| `GET /api/notes?paper=&project=`, `POST /api/notes`, `PATCH /api/notes/{id}`, `DELETE /api/notes/{id}` | `note list`, `note create [--project-id]`, `note update`, `note delete` (permanent); title/content capped at 100 kB |\n| `GET /api/annotations?paper=&project=`, `POST /api/annotations`, `PATCH /api/annotations/{id}`, `DELETE /api/annotations/{id}` | `annotation list/create/update/delete` (PDF highlights; anchors validated server-side) |\n| `GET /api/graph` | Knowledge graph of papers, authors and tags, built from `list` and `author list` |\n| `GET /api/feed`, `POST /api/feed/dismiss` | `feed get` for the stored home feed URL (cached 5 minutes), `feed dismiss` |\n| `GET /api/feed/rules`, `POST /api/feed/rules`, `DELETE /api/feed/rules/{id}` | `feed rules list/add/delete` |\n| `GET /api/versions/new`, `POST /api/versions/check` `{limit?}`, `POST /api/versions/{id}/ack` | `versions new`, `versions check --limit` (1 to 100, default 20; stores new versions it finds), `versions ack` (a second ack still returns ok) |\n| `POST /api/papers/{id}/source` `{force?}` | `paper fetch-source [--force]`: downloads the paper's arXiv LaTeX source and indexes its text for full-text search (400 for non-arXiv papers) |\n| `GET /api/sources/pending`, `GET /api/sources/worker` | Saved arXiv papers whose source isn't indexed; status of the background full-text indexer |\n| `POST /api/jobs/index-sources` `{limit?}`, `GET /api/jobs?kind=`, `GET /api/jobs/{id}`, `POST /api/jobs/{id}/cancel` | Background job: one `paper fetch-source` per pending paper, 7 s apart (409 if one is already running) |\n| `GET /api/trash` | `trash list` |\n| `POST /api/trash/papers/{id}/restore`, `DELETE /api/trash/papers/{id}` | `trash restore`, `trash hard-delete` (paper must be in the trash) |\n| `POST /api/trash/projects/{id}/restore`, `DELETE /api/trash/projects/{id}` | `trash restore-project`, `trash hard-delete-project` |\n| `GET /api/backup`, `POST /api/backup/import`, `POST /api/backup/restore` (multipart `file`) | `backup` (database snapshot download), `import` (merge another database), `restore` (replace the library; refused while a job runs) |\n| `GET /api/settings/export`, `POST /api/settings/import` (the exported JSON) | Settings that `PATCH /api/settings` accepts, as a download without API keys or tokens (admins also get the server-wide keys); import applies a file's `settings` through the same checks, refusing API keys and unknown keys |\n| `GET /api/maintenance`, `PUT /api/maintenance/schedule` `{hour, nightly[], keep_backups?, trash_days? (0-120)}`, `POST /api/maintenance/{op}/run` | Admin only. Maintenance operations (`duplicates`, `complete-metadata`, `pdf-check`, `backup`, `clean-authors`, `empty-trash`) with their last result and running job; the nightly hour (server local time) and ticked operations; start one as job `maint-<op>` over every library (409 while it runs) |\n| `GET /api/maintenance/backups`, `GET`/`DELETE /api/maintenance/backups/{library}/{name}` | Admin only. Database backups the `backup` operation keeps in each library's `maintenance/backups/`: list, download, delete |\n| `GET`/`PUT /api/maintenance/complete-metadata/options` `{gaps[], sources[]}` | Admin only. Which gaps (`title`, `authors`, `date`, `abstract`, `journal`, `doi`) make a new paper incomplete, and which sources (`arxiv`, `biorxiv`, `crossref`, `pubmed`) are asked, in order; at least one of each |\n| `GET /api/maintenance/pdf-check` | Admin only. Last `pdf-check` report per library: missing PDF files and files no paper points at (lists capped at 500, counts complete) |\n| `GET /api/duplicates`, `POST /api/duplicates/merge` `{winner, loser}`, `POST /api/duplicates/dismiss` `{source_ids}` | Groups of papers with identical PDF files from the last `duplicates` run in this library; `paper merge` keeps the winner; dismissed groups stay hidden on later runs |\n| `GET /api/metadata-review`, `POST /api/metadata-review/remove` `{source_ids}` | Papers `complete-metadata` could not complete in this library (fixed or deleted ones drop out); take papers off the list |\n| `GET /api/settings`, `PATCH /api/settings` | `settings get`, `settings update`, limited to an allowlist (storage cap, TeX rendering, contact addresses, home feed, search history, full-text worker, AI and MCP settings); API keys are write-only |\n| `GET /api/ai/models`, `POST /api/ai/test`, `GET /api/ai/schemas` | Provider model list, connection test, the JSON Schema and example each AI action uses (409 while AI is off) |\n| `POST /api/ai/tags/{id}`, `POST /api/ai/summary/{id}?pdf=`, `POST /api/ai/cleanup/{id}` | AI tag suggestions, summary, metadata cleanup; previews only, nothing is written |\n| `GET /api/mcp`, `POST /api/mcp/token`, `DELETE /api/mcp/token` | MCP status, create (shown once) or revoke the access token |\n| `POST/GET/DELETE /mcp` | MCP Streamable HTTP endpoint (bearer token; see above) |\n\nPaper ids are linXiv source ids such as `arxiv:1706.03762`. Old-style arXiv ids keep their slash: `/api/papers/arxiv:hep-th/9901001`.\n\n## Development\n\nEverything runs in containers. Although bare metal install is not supported, it is feasible to do without code change. \n\n```sh\n# Server: ruff + pytest, including tests against the real linxiv and linxiv-mcp binaries\ndocker build --target server-test .\n\n# Frontend: lint, type check, build\ndocker run --rm -v \"$PWD/web\":/w -w /w node:22-slim sh -c 'npm ci && npm run lint && npm run build'\n\n# Just the CLI (handy for exploring its JSON output)\ndocker build --target cli -t linxiv-cli .\ndocker run --rm -v lxw-scratch:/data linxiv-cli stats\n```\n\nTo build against a local linXiv checkout instead of a tag (its submodules must be initialised):\n\n```sh\ndocker build --build-arg LINXIV_SRC=local --build-context linxiv-local=../linXiv .\n```\n\nThe FastAPI server lives in `server/`: the CLI subprocess wrapper is `app/cli.py`, the AI provider client `app/ai.py`, and the MCP bridge `app/mcp_bridge.py`. The Vite, React and TypeScript frontend lives in `web/`. The `Dockerfile` builds the CLI and MCP server first, then the SPA, then the Python runtime image.\n\nBranch and commit conventions match linXiv's; see [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Roadmap\n\n- Bring the Peer-to-peer project sharing into the web-app\n- Enable multi user for a shared server, individual db, but also shared files across users\n- Re-design UI for individual paper view\n- Deployment of Home Assistant Addon\n\n## Use of AI\n\nThe frontend was refactored by Claude from a project [CatalogueCanvas](https://github.com/CatalogueCanvas/CatalogueCanvas). All code has been reviewed/audited for safety and logic using Codacy, GitHub CodeQL, and GitHub Advanced Security. Most of the documentation files were written by Claude.\n\nFeel free to vibe code suggestions, improvements, etc... but you will be held accountable :-) \n\n## License\n\n[Apache-2.0](LICENSE). See [NOTICE](NOTICE).\n"}