AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
LLM 에이전트를 위해 8개 제공처(OpenAlex 등)의 학술 논문을 조회·읽기·교차참조하는 식별자 기반 도구를 제공하는 MCP 서버입니다
MCP server giving LLM agents lean, identifier-routed tools to look up, read, and cross-reference academic papers across 8 providers (OpenAlex, arXiv, bioRxiv, ACL Anthology, Crossref, OpenCitations, P
개발 도구Python · ShellGitHub @hunter-heidenreich직접 설치운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
MCP server giving LLM agents lean, identifier-routed tools to look up, read, and cross-reference academic papers across 8 providers (OpenAlex, arXiv, bioRxiv, ACL Anthology, Crossref, OpenCitations, Papers with Code, Wikipedia).
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
An MCP server that gives LLM agents lean, focused tools for working with academic papers. Built on FastMCP. Look up paper metadata, authors, abstracts, citations, and BibTeX entries. Download and read full paper PDFs section-by-section. Explore reference and citation graphs. Find a paper's code, benchmark results and leaderboards on Papers with Code. Cross-reference with Wikipedia. [README 설치·사용 부분 발췌] Setup Requires Python 3.11+ and uv.README 전문 보기 ↗
An MCP server that gives LLM agents lean, focused tools for working with academic papers. Built on FastMCP.
Look up paper metadata, authors, abstracts, citations, and BibTeX entries. Download and read full paper PDFs section-by-section. Explore reference and citation graphs. Find a paper's code, benchmark results and leaderboards on Papers with Code. Cross-reference with Wikipedia.
| Provider | What it provides | Auth required |
|---|---|---|
| OpenAlex | Paper metadata, authors, institutions, abstracts, topics, citations, BibTeX | Optional API key (free, raises the credit budget 10×) |
| arXiv | Preprint metadata, authors, abstracts, BibTeX, license and revision history, PDF download, HTML full text | None |
| bioRxiv/medRxiv | Preprint metadata, authors, abstracts, journal version, funders, revision history, BibTeX, PDF download | None |
| ACL Anthology | Metadata, authors, abstracts, BibTeX and PDF download for ACL venue papers | None |
| Crossref | Reference lists, retraction and correction notices, title search / DOI discovery | Optional email (for polite pool) |
| OpenCitations | Reference and citation links with cross-referenced IDs | Optional access token (free, usage statistics only) |
| Wikipedia | Full-text article search, summaries, Wikidata QIDs | Optional email (for User-Agent) |
| Papers with Code | Code repositories, tasks, methods, benchmark results and leaderboards, Hugging Face links (arXiv papers) | None |
All API responses are cached locally. Multiple tool calls for the same paper = one API hit. Concurrent calls for the same paper are coalesced into a single fetch (request single-flight), transient failures (5xx, 429, timeouts) get one transparent retry honouring Retry-After, and definitive 404s are negative-cached (24h; 1h for arXiv/bioRxiv, whose identifiers go live mid-session) so retry-happy agents don't burn rate budget on guaranteed misses.
These are properties of the upstream providers rather than of this server, which does not attempt to paper over them. Treat them as caveats when citing a result.
Alan Aspuru-Guzik for Alán Aspuru-Guzik). Verify spellings against the publisher's page before quoting a name.autocomplete_openalex cost nothing, a batched get_papers_metadata call costs 1, and search_openalex / search_authors cost 10 each, against 1000/day anonymous and 10× that with a free OPENALEX_API_KEY. So lookups are effectively unlimited and searches are not — roughly 100 a day without a key.get_paper_references(source="opencitations") and get_paper_citations returning total: 0 mean "no edges in this index", not "this paper has no references or citations". Cross-check against Crossref for references (get_paper_references_count reports both) and against OpenAlex for citations (get_paper_citations_count reports both).10.1038/nature14539 504s after 280s on even its cheap count endpoint — so get_paper_citations errors for them. The _count tools fall back to a tally-only endpoint and flag it pageable: false, on the source row and beside get_paper_citations_count's top-level count: the count is real, the edge list is not available at all. Take the magnitude from OpenAlex, and the references from the paper's own bibliography.hf_models / hf_datasets / hf_spaces URL lists only for papers not from arXiv. Repository links, is_official and leaderboard rows are community-curated: cite a result's source paper, not the leaderboard.Requires Python 3.11+ and uv.
git clone https://github.com/hunter-heidenreich/academic-tools-mcp.git
cd academic-tools-mcp
uv sync
cp .env.example .env # then edit .env with your values
All configuration is via environment variables in .env. Nothing is required to get started, but some variables unlock higher rate limits.
| Variable | Required | Description |
|---|---|---|
OPENALEX_API_KEY | No | Free API key from openalex.org. OpenAlex meters a credit budget per ~24h window; a key raises it 10× (measured 1000 → 10000). OPENALEX_MAILTO does not raise it |
OPENALEX_MAILTO | No | Your email, appended to the OpenAlex User-Agent. Buys no rate tier: OpenAlex abolished the polite pool and the mailto parameter in Feb 2026 and meters by key. It leaves an operator reachable, which OpenAlex still asks for. |
CROSSREF_MAILTO | No | Your email — joins the Crossref polite pool, raising the rate limit to 10 req/sec singles / 3 search / 3 concurrent, from 5 / 1 / 1. Sent in the User-Agent and as a mailto parameter, since Crossref meters the pool by address. The client starts at the public rate and speeds up only once a response confirms it, so a typo costs throughput rather than earning a 429. |
OPENCITATIONS_ACCESS_TOKEN | No | Free token from opencitations.net, sent in the authorization header. Buys no rate tier — OpenCitations issues it to count unique users — but those counts are how the project evidences its own relevance. A token it rejects 403s every request, so leave it blank rather than guessing; the error then names this variable. |
OPENCITATIONS_MAILTO | No | Your email, appended to the OpenCitations User-Agent. Buys no rate tier either; it leaves an operator reachable. |
ARXIV_MAILTO | No | Your email, appended to the arXiv User-Agent. A descriptive agent is sent either way — arXiv's edge throttles generic library agents far harder. |
CACHE_DIR | No | Where the on-disk cache lives (default: .cache/ beside the project). Set it when running from an installed wheel. |
ACADEMIC_TOOLS_ENV_FILE | No | Explicit path to the .env to load. Authoritative: when set it is the candidate, so a path that isn't there means no rather than a silent fallback to another file. |
Setting ACADEMIC_TOOLS_ENV_FILE makes that file the only candidate. Otherwise .env is looked for in order: the project root (source checkouts), $PWD/.env, then $XDG_CONFIG_HOME/academic-tools-mcp/.env (falling back to ~/.config). Real environment variables always win over the file, and a file that is unreadable or isn't UTF-8 is skipped rather than fatal.
Every value has its surrounding whitespace stripped, and one that is left empty reads as unset — a trailing space in a .env line is a typo, not a setting. Whitespace inside a value (a converter command template, a path) is preserved.
Add to your MCP config (~/.claude/claude_code_config.json):
{
"mcpServers": {
"academic-tools": {
"command": "uv",
"args": ["run", "--directory", "/path/to/academic-tools-mcp", "python", "-m", "academic_tools_mcp.server"]
}
}
}
uv run python -m academic_tools_mcp.server
uv run fastmcp run src/academic_tools_mcp/server.py:mcp
| Tool | Description |
|---|---|
get_paper_metadata | Title, dates, venue / categories, identifiers, citation count — shape varies by _source. Optional follow_published=True auto-chains a bioRxiv or arXiv preprint to its journal version on OpenAlex when one exists. |
get_papers_metadata | Bulk metadata for many identifiers at once. Uncached OpenAlex DOIs are chunked into batched /works?filter=doi:... calls and uncached arXiv IDs into id_list calls — a cached one costs no request at all; bioRxiv / ACL Anthology fan out concurrently. Designed for reference-graph enrichment after get_paper_references. Cap 100 per call. |
get_paper_authors | Author list with source-appropriate detail (affiliations, corresponding author, OpenAlex IDs) |
get_paper_abstract | Plain text abstract |
get_paper_bibtex | Ready-to-paste BibTeX entry |
get_paper_versions | arXiv IDs and bioRxiv/medRxiv DOIs: license and every revision's date (plus submitter and size on arXiv, per-revision license on bioRxiv). The license decides whether a paper may be redistributed. |
Pass an arXiv ID, an ACL Anthology ID, any DOI, or a PMID. Each response carries a _source field ("arxiv" / "biorxiv" / "acl_anthology" / "openalex") so you know which provider answered and which fields to expect; follow_published adds "openalex_via_biorxiv" / "openalex_via_arxiv" when the chain reaches the journal version. arXiv IDs always route to arXiv; bioRxiv DOIs (10.1101/...) route to bioRxiv; ACL Anthology IDs, URLs and hosted DOIs route to the Anthology, keyed by Anthology ID; everything else routes to OpenAlex.
All four take fallback_crossref=True. When OpenAlex returns a definitive 404 for a DOI — a paper published last week, indexed by Crossref and not yet by OpenAlex — the answer comes from Crossref instead, tagged _source: "crossref". Off by default, and never triggered by a transient OpenAlex failure. What Crossref cannot supply is null rather than absent: no open-access fields, no OpenAlex author IDs or corresponding-author flag, and no abstract on the many records deposited without one. If Crossref itself fails transiently, the OpenAlex error carries crossref_fallback_retryable: true, so a retry is distinguishable from a paper neither index has.
bioRxiv needs no flag: a transient bioRxiv failure on a 10.1101/... DOI is answered from OpenAlex, with _source: "openalex" and biorxiv_unavailable: true. A definitive miss or local backpressure never falls back; if OpenAlex also fails transiently, the error carries openalex_fallback_retryable: true.
A PubMed ID works anywhere a DOI does — the paper tools, the PDF pipeline, and the reference/citation graph, whose OpenCitations rows hand PMIDs back. Spell it pmid:20079334, as a pubmed.ncbi.nlm.nih.gov URL, or as a bare 7–8 digit run; shorter runs need the pmid: prefix, so a short freeform import_paper label keeps meaning what it did. A PMID is traded for the paper's DOI on first use and never becomes a cache key of its own, so pmid:20079334 and 10.1016/j.cell.2009.11.006 are one paper with one _canonical_id. OpenAlex is the resolver: a paper it has not indexed does not resolve, and PMCIDs are unsupported.
An OpenAlex work ID works the same way, and closes a dead end: search_openalex reports an openalex_id on every hit where a doi is often absent, and roughly 1.4% of OpenCitations graph rows carry one and no DOI. Spell it W4312223440, openalex:-prefixed, or as an openalex.org URL; a bare run under eight digits needs the prefix, so a short freeform import_paper label keeps meaning what it did. It too trades for the paper's DOI on first use rather than becoming a second cache key, and a work OpenAlex indexes without a DOI is refused by name rather than 404'd.
An arXiv ID is accepted in every spelling that names the same paper, so one paper never caches twice: bare (2301.00001, 2301.00001v2, hep-th/9901001), arXiv's arXiv: "Cite as" prefix, an abs/pdf/html URL (any scheme or none, with or without a www./export. host label), and arXiv's own DataCite DOI (10.48550/arXiv.2301.00001). The version suffix is part of the identity: 2301.00001 means "whatever is current" and 2301.00001v2 means that revision, and the two cache separately.
| Tool | Description |
|---|---|
search_arxiv | Search arXiv with field prefixes (ti:, au:, abs:, cat:), boolean operators and submittedDate:[… TO …] ranges; pages with page, sorts by relevance, submission or update date. arXiv serves only a query's first 10,000 results, so narrow the query rather than paging past them. |
search_openalex | Free-text search across all of OpenAlex, matched on title, abstract and fulltext. The broadest discovery tool here — use it when you have a topic rather than a title. Each hit with a DOI warms the cache get_paper_metadata reads, so the follow-up is free; a doi: null hit is not fetchable by these DOI-keyed tools at all, and needs import_paper. The one OpenAlex call that spends real credit budget — use autocomplete_openalex when you have a name rather than a topic |
autocomplete_openalex | Match a name or title prefix to an OpenAlex ID, across works, authors, institutions or sources. Costs no credit budget, so reach for it when you know what something is called. Hits are not cached, so chaining one costs a request |
| Tool | Description |
|---|---|
search_authors | Find an author by name. Returns the several records one name often matches, with h-index, works/citation counts and a current institution to tell them apart. Each hit warms the cache get_author reads, so the profile is free |
get_author | Name, ORCID, institutions (current + historical with years), h-index, i10-index, works/citation counts, top topics |
get_institution | Name, ROR, country, type, homepage, city/region, alternative names and acronyms, parent institutions, h-index, works/citation counts. Reached from autocomplete_openalex, which resolves an affiliation string to an ID |
get_author accepts OpenAlex author IDs (from get_paper_authors or
search_authors) and ORCIDs in every spelling — bare, orcid:-prefixed, or an
orcid.org URL — all of which resolve to one cached author.
get_institution accepts OpenAlex institution IDs and RORs on the same terms —
bare, ror:-prefixed, or a ror.org URL. It resolves what an affiliation string
names, not where an author worked when a given paper was written.
| Tool | Description |
|---|---|
download_pdf | Download and cache the PDF — auto-detects arXiv, ACL Anthology, bioRxiv/medRxiv. Streams chunks to disk (peak memory = 64 KiB) and aborts mid-stream if the response would exceed MAX_PDF_BYTES (default 200 MB). Whenever it actually downloads (not on a cache hit), the cached markdown + section index are dropped automatically so the next convert_paper picks up the new bytes. Markdown you imported yourself, or converted from arXiv's HTML, survives that; pass force_refresh=True to replace it too. |
convert_paper | Convert a paper to markdown, parse into sections. arXiv papers use arXiv's HTML first, bioRxiv/medRxiv papers bioRxiv's JATS XML — seconds, no PDF needed. Otherwise it converts the downloaded PDF (slow: tens of minutes; PDF_CONVERT_TIMEOUT caps it at 30 min by default). The server runs at most one conversion at a time across all callers — a second concurrent caller gets {busy: True, retryable: True, in_progress: {...}} immediately rather than queueing |
get_paper_sections | Section index with titles, sub-heading previews, token counts, and conversion_mode — what produced the markdown (full / fast / html / jats / imported) |
get_paper_section | Markdown of a section (by index or title substring); truncated by default (16000 chars) |
find_in_paper | Substring (or whole-word) search inside one converted paper. Returns each hit's section + char offset + ~120-char snippet, and echoes paper_identifier in canonical form. Char offsets align with get_paper_section's stripped text so you can chain straight to the surrounding context. |
search_cached_papers | BM25 keyword search across every converted paper in the local cache. Answers "which paper mentioned X?"; pair it with find_in_paper for "where in that paper?". Papers the index could never use are reported separately as unindexable, each with a canonical_id you can hand straight to find_in_paper. |
convert_paper(mode="fast") runs a lightweight text-only extractor (PDF_FAST_CONVERTER, default pdftotext) outside the global conversion lock — seconds instead of minutes, but no tables, equations, figures, or real headings. Use it for triage; re-run in full mode when you need structure.
download_pdf natively handles arXiv, bioRxiv/medRxiv, and ACL Anthology. For any other publisher DOI it refuses by default rather than fetching arbitrary URLs; download_pdf(doi, allow_oa_url=True) opts into a narrow exception that fetches only the open-access PDF URL OpenAlex already surfaces for that work — never a caller-supplied URL.
Every tool above except search_cached_papers (which takes a query, not a paper) accepts any identifier — arXiv ID, DOI, or freeform label — and auto-routes to the correct provider's cache namespace. For papers not hosted on arXiv/ACL/bioRxiv, fetch the PDF yourself and hand it to import_paper — see Manual import below.
| Tool | Description |
|---|---|
get_paper_references_count | Survey outgoing-reference coverage across both Crossref and OpenCitations in one call — returns per-source counts so you can pick which to page through; a count marked pageable: false is real but has no rows behind it |
get_paper_references | Paginated outgoing references. Default source="auto" surveys both Crossref and OpenCitations in parallel and pages from the better-covered one, biased toward Crossref for its richer per-entry metadata (OpenCitations wins only on a materially larger reference list); pass source="crossref" for structured metadata or source="opencitations" for broader DOI coverage to skip the survey |
get_paper_citations_count | Survey incoming-citation coverage across OpenCitations and OpenAlex in one call — the cross-check that tells an OpenCitations zero from a genuinely uncited paper, and the only tool that answers when the citation list is too large to fetch |
get_paper_citations | Paginated incoming citations with DOIs, dates, self-citation flags, and cross-referenced IDs (OpenCitations) |
get_paper_updates | Retraction and correction notices for a DOI, from Crossref and the Retraction Watch database, plus any preprint/published DOIs the publisher deposited. Free on a paper already in the Crossref cache |
search_crossref_by_title | DOI discovery by bibliographic query (also works for bioRxiv papers); max_results widens the triage list up to Crossref's cap. Each hit warms the Crossref works cache, so a follow-up get_paper_references(doi, source="crossref") is free |
For citations, follow the count-then-page pattern: call get_paper_citations_count first to see the total, then page through with page and page_size. For references the source="auto" default does the survey for you on the first call. Paginated responses include _source and has_more so agents know which shape to expect and when to stop, and echo doi in canonical form so every spelling of one paper correlates to a single value across calls. This prevents token blowouts on papers with long bibliographies or many citations.
Source trade-off for references: Crossref returns structured reference metadata (author, title, year, journal, DOI) when publishers deposit it; quality varies. OpenCitations aggregates from Crossref, PubMed, DataCite, OpenAIRE, and JaLC — it may have entries Crossref lacks, but returns DOI-to-DOI links only (no bibliographic metadata).
| Tool | Description |
|---|---|
import_paper | Import a local .pdf (e.g. from Zotero or a file you downloaded) or pre-converted .md/.markdown with a user-supplied identifier. File type is detected by extension. |
For PDFs outside arXiv/bioRxiv/ACL, fetch the file yourself (browser, curl, publisher portal, institutional proxy) and then call import_paper — the server deliberately does not download arbitrary URLs.
After importing a PDF, use the unified pipeline tools (convert_paper → get_paper_sections → get_paper_section) with the same identifier. Markdown imports skip the conversion step and go straight to get_paper_sections / get_paper_section.
Provider-aware routing: if the identifier is an arXiv ID, bioRxiv DOI, or ACL Anthology ID, URL or DOI, the file is stored in that provider's cache namespace automatically. A subsequent download_pdf("2301.00001") will find an already-imported PDF — no duplicates.
| Tool | Description |
|---|---|
get_paper_code | Linked repositories (official first, then by stars, plus the total count), project pages, Hugging Face artifacts |
get_paper_catalog | Tasks, methods, introduced benchmarks and frameworks, best leaderboard ranks, predecessor and successor papers, organizations, a machine-generated TL;DR, authors' Hugging Face accounts |
get_paper_evaluations | Every benchmark result a paper reports (model, metrics, rank, parameter count, open or closed), paged |
get_pwc_task | A task by slug, display name or ID, with paper, benchmark and evaluation counts |
get_benchmark_leaderboard | A benchmark's details (license, modalities, introducing paper) and its leaderboard, best rank first; optionally open models only |
search_paperswithcode | Keyword search, optionally limited to official code or a date range |
The paper tools take an arXiv ID in any spelling; a DOI is refused without a request. get_paper_code and get_paper_catalog share one cached record, so calling both costs one request.
Rate limits are treated as hard. Requests go out one at a time at no more than 90% of the documented sustained rates, never using the burst allowance. Nothing is retried automatically or paged on the agent's behalf. After a 429, every Papers with Code call returns {error, retryable, quota_exhausted, retry_after_seconds} without a request until Retry-After passes.
| Tool | Description |
|---|---|
search_wikipedia | Full-text search of article content, not just titles; hits carry a plain-text snippet, and did_you_mean returns Wikipedia's spelling correction when a query finds nothing |
get_wikipedia_summary | Title, redirect-resolved canonical_title, description, extract, URL, page type (standard / disambiguation), pageid, revision + timestamp and a permalink pinning them, and wikibase_item — the Wikidata QID that chains on to ORCID and ROR; errors with not_found if the page doesn't exist |
The PDF-to-markdown pipeline converts downloaded PDFs into section-level markdown that agents can read piece by piece, avoiding token blowouts from dumping entire papers into context.
arXiv papers skip the converter when they can. convert_paper first fetches arXiv's LaTeXML rendering (arxiv.org/html/<id>) and turns it into markdown in-process: sections as headings, equations as their LaTeX source, tables as pipe tables, captions kept and images dropped. It records conversion_mode: "html", and a later PDF download leaves that markdown in place. Only papers without a rendering go through the converter below.
bioRxiv/medRxiv papers do the same with bioRxiv's JATS XML (the record's jatsxml), recorded as conversion_mode: "jats". Most tables and many equations ship as images, which JATS carries only as captions — convert the PDF when you need them.
The pipeline is converter-agnostic. Set PDF_CONVERTER in .env to choose your backend:
# Named backends
PDF_CONVERTER=mineru # default — https://github.com/opendatalab/MinerU
PDF_CONVERTER=marker # https://github.com/datalab-to/marker
# Custom command template. Placeholders: {input} (the PDF), {output_dir}
# (where to write markdown), {python} (this server's own interpreter).
# Use them BARE — the values are substituted already shell-quoted, so
# wrapping one in quotes yourself breaks every path with a space in it.
PDF_CONVERTER=my-tool --in {input} --out {output_dir}
PDF_CONVERTER={python} -m my_tool {input} {output_dir}
If your converter lives in a virtualenv, set PDF_CONVERTER_VENV:
PDF_CONVERTER_VENV=~/.venvs/mineru
The converter must accept a PDF input path and an output directory, and produce one or more .md files in that directory. The pipeline picks one deterministically: a file named after the PDF beats any other .md, and within each of those two passes the shallowest path wins, then alphabetical order. (MinerU's <stem>/{auto,ocr,txt}/<stem>.md layout therefore resolves to auto.)
PDF_FAST_CONVERTER takes the same {input} / {python} placeholders, under the same bare-placeholder rule. It has no {output_dir}: a fast backend must write the extracted text to stdout and its diagnostics to stderr.
Note: PDF converters are external tools with their own licenses. MinerU is AGPL-3.0; Marker is GPL. This project invokes them as CLI subprocesses and does not link or import their code. The PDF pipeline is entirely optional — all metadata, BibTeX, and citation tools work without it.
python -m venv ~/.venvs/mineru
source ~/.venvs/mineru/bin/activate
pip install mineru
Then in .env:
PDF_CONVERTER=mineru
PDF_CONVERTER_VENV=~/.venvs/mineru
API responses and downloaded files are cached under .cache/:
.cache/
openalex/works/ # OpenAlex work objects (JSON)
openalex/authors/ # OpenAlex author objects (JSON)
openalex/institutions/ # OpenAlex institution objects (JSON)
openalex/pmids/ # PMID -> DOI mappings (JSON)
arxiv/papers/ # arXiv paper entries (JSON)
arxiv/pdfs/ # Downloaded PDFs
arxiv/markdown/ # Converted markdown
arxiv/sections/ # Section indices (JSON)
biorxiv/papers/ # bioRxiv paper entries (JSON)
biorxiv/pubs/ # Journal versions (JSON)
biorxiv/pdfs/ # Downloaded PDFs
biorxiv/markdown/ # Converted markdown
biorxiv/sections/ # Section indices (JSON)
acl_anthology/pdfs/ # Downloaded PDFs
acl_anthology/markdown/ # Converted markdown
acl_anthology/sections/ # Section indices (JSON)
crossref/works/ # Crossref work objects (JSON)
opencitations/references/# OpenCitations reference lists (JSON)
opencitations/citations/ # OpenCitations citation lists (JSON)
opencitations/reference-count/ # Reference tallies (JSON, the fallback endpoint)
opencitations/citation-count/ # Citation tallies (JSON, the fallback endpoint)
wikipedia/summaries/ # Wikipedia page summaries (JSON)
paperswithcode/papers/ # Papers with Code catalog records (JSON)
paperswithcode/evaluations/ # Evaluation / leaderboard pages (JSON)
paperswithcode/tasks/ # Tasks (JSON)
paperswithcode/datasets/ # Benchmarks (JSON)
paperswithcode/searches/ # Search result pages (JSON)
<namespace>/downloads/_neg/ # Definitive PDF-download failures (TTL below)
oa_download/downloads/ # Open-access fetch bookkeeping (the PDF itself is
# filed under the routed provider's namespace)
manual/pdfs/ # Manually imported PDFs
manual/markdown/ # Converted markdown
manual/sections/ # Section indices (JSON)
__search_index__/ # SQLite FTS5 index behind search_cached_papers
Cache keys are SHA-256 hashes of canonical identifiers. Writes are atomic (temp file + os.replace) so a crash mid-write can't leave a corrupt entry; corrupt entries from earlier versions self-heal on read. A write the filesystem refuses — a full disk, a read-only CACHE_DIR — is reported as an error rather than raised, and counted as cache_write_failures in get_server_stats.
CACHE_DIR is best pointed at a POSIX filesystem. On one without hard links (exFAT, some network mounts) the startup sweep that renames artifacts written under older filename rules is skipped rather than run unsafely, so those files keep their old names and are re-downloaded once if they're ever asked for.
Positive entries expire per provider, so a long session doesn't serve stale data:
| Provider | Positive TTL | Negative TTL | Why |
|---|---|---|---|
| arxiv | 14d | 1h | New versions land under a new key; preprint IDs go live mid-session. |
| arxiv (versions) | 1d | 1h | The revision history changes exactly when a new version lands. |
| biorxiv | 7d | 1h | published_doi appears asynchronously once a preprint is published. |
| biorxiv (pubs) | 7d | 1h | A preprint can gain a journal link any day. |
| openalex (works, authors, institutions, pmids, work_ids) | 30d | 24h | Citation counts, topics, h-index all drift. |
| crossref | 30d | 24h | Reference lists grow as publishers re-deposit metadata. |
| opencitations | 7d | 24h | The citation graph grows continuously. |
| wikipedia | 30d | 24h | Articles change as they're edited. |
| paperswithcode (papers, evaluations, datasets) | 7d | 24h | Repository stars drift; leaderboards gain rows. |
| paperswithcode (tasks) | 30d | 24h | The taxonomy is curated and slow-moving. |
| paperswithcode (searches) | 1d | 24h | Results reorder as papers are ingested. |
| acl_anthology | 7d | 24h (1h for a paper missing from its collection) | Collection files change only through corrections. |
arXiv HTML renderings that don't exist are negative-cached for 1h under an html entity, and bioRxiv JATS files under a jats entity; the rendering itself is not cached, only the markdown made from it.
PDF downloads negative-cache definitive failures too, under a downloads entity in each provider's namespace: arxiv / biorxiv 1h (they render PDFs lazily, so a just-announced paper's PDF can 404 for minutes), acl_anthology and the open-access path 24h (static files — a 404 means a wrong ID or a closed-access paper). The PDF itself never expires.
Eviction is mtime-based and self-healing: an over-age entry is unlinked on read. Negative entries (definitive 404s) live in a sibling _neg/ subdirectory so a known-bad identifier doesn't burn rate budget on every retry, while a newly-registered DOI still surfaces within the TTL. force_refresh=True drops both halves and re-fetches.
Downloaded PDFs, converted markdown, and section indices have no expiry — they're derived from bytes that don't change. .cache/ therefore grows without bound; prune it by hand if it gets large.
uv sync # Install dependencies
uv run pytest -v # Run all tests
uv run pytest tests/test_bibtex.py -v # Run one test file
uv run pytest -k "test_particle" -v # Run tests matching a pattern
# Coverage, as CI runs it. CI gates at 90%; the flags are not in `addopts`
# because a single-file run would then fail the floor.
uv run pytest -q --cov=academic_tools_mcp --cov-report=term-missing --cov-fail-under=90
server.py thin entry: re-exports mcp + tools, registers the
│ optional debug tool
│
├── app.py FastMCP instance, lifespan, shared Annotated param types
├── manual.py local-file import + identifier dispatch
├── corpus.py BM25 over cached markdown (SQLite FTS5)
├── bibtex.py BibTeX generation
├── fast_extract.py bundled pymupdf text extractor (a `python -m` target)
│
├── tools/ 33 @mcp.tool functions, split by job
│ paper.py metadata / authors / abstract / bibtex
│ pipeline.py download → convert → sections → section
│ graph.py references and citations
│ search.py arXiv / OpenAlex / Crossref / Wikipedia /
│ local corpus
│ catalog.py Papers with Code: code, tasks, leaderboards
│
├── providers/ eight API clients, all the same shape
│ openalex.py arxiv.py biorxiv.py crossref.py
│ opencitations.py wikipedia.py acl.py
│ paperswithcode.py
│
├── papers/ PDF → markdown → sections
│ sections.py markdown structure + search
│ index.py the section index + its lock
│ convert.py converter subprocess + gate
│
├── download/ getting PDF bytes onto disk
│ streaming.py streaming download, size cap, cached-download
│ openaccess.py gated open-access fetch for generic DOIs
│
├── net/ the outbound network edge
│ http.py retry honouring Retry-After, structured errors
│ throttle.py burst cap → concurrency cap → inter-start gap
│ clients.py per-provider pooled httpx.AsyncClient
│ stats.py per-provider counters, DEBUG_REQUESTS logging
│
├── store/ the on-disk cache (every API client routes through this)
│ cache.py atomic file cache, TTLs, negative cache
│ atomic.py temp-file + os.replace, the one write seam
│ singleflight.py same-key callers coalesce to one fetch
│ stems.py artifact naming — one sanitizer, every path
│
└── util/ leaf helpers — every module here imports nothing else
config.py .env + environment resolution
doinorm.py DOI normalization — one home, every caller
textnorm.py diacritic folding + maps back to original offsets
useragent.py the outbound User-Agent — one home, every client
Key design decisions:
get_paper_authors doesn't get flooded with unrelated metadata.get_paper_metadata, get_paper_authors, get_paper_abstract, get_paper_bibtex) dispatch on identifier shape rather than forcing the agent to pick between arXiv/bioRxiv/OpenAlex families. Provider-native fields are preserved and tagged with _source.get_papers_metadata collapses N parallel singletons into batched /works?filter=doi:...|... calls over the OpenAlex DOIs that actually miss the cache, and batched id_list calls over the arXiv IDs that miss, plus concurrent fan-out for bioRxiv — designed for reference-graph enrichment.httpx.AsyncClient so TCP+TLS handshakes are reused. Transient failures (5xx, 429, timeouts, network errors) get one in-process retry honouring Retry-After in either form RFC 9110 permits — delay-seconds or HTTP-date — capped at 10 minutes, before surfacing to the agent. arXiv retries twice instead of once: its edge returns 429/503 with no Retry-After, and a single retry tends to land in the same cooldown.X-RateLimit-* headers answers 429 with Retry-After, every later call to it is refused locally until that time, instead of adding requests to the cooldown. Papers with Code, whose per-IP limit your browser shares, also gets no automatic retry.This project uses calendar versioning: each release is named for the day it
was cut — YYYY.MM.DD, tagged vYYYY.MM.DD in git (a rare same-day re-release
takes a .postN suffix). See CHANGELOG.md for the release
history.
MIT — see LICENSE.
en.wikipedia.orgextractpermalinkrevisionurlget_paper_authors on an arXiv ID usually lists names only. Chain to the journal version with follow_published=True, or look the author up with search_authors / get_author, keeping in mind that OpenAlex's affiliations are current rather than paper-time.10.1162/tacl…) answers from OpenAlex until the weekly index refresh.2301.99999, or a typo — this way, and does the same for some field-prefixed search queries and for any request following too closely behind an earlier rejection. The server reports it as a transient failure and re-requests rather than recording the paper as absent, because the one status covers both cases: if get_paper_metadata returns it for an ID you believe in, wait and retry before concluding the paper is gone. The PDF and HTML hosts ride a different edge and keep serving throughout, so download_pdf falls back to them when only the metadata call was refused.convert_paper then needs the PDF. The rendering itself can lose content LaTeXML could not parse — an undefined macro, a complex table, a figure drawn in TikZ./details API has served empty responses for every query; bioRxiv DOIs then answer from OpenAlex, without published_doi or corresponding-author fields. Its PDF hosts sit behind Cloudflare; when download_pdf is refused, fetch the PDF in a browser and use import_paper.retracted: false from get_paper_updates is not a clean bill of health. Crossref answers a DOI nobody has deposited a notice against and a sound paper identically, so it clears a citation rather than certifying one. The flag covers the whole withdrawing class (retraction, partial_retraction, withdrawal, removal); an amending notice — a correction, an erratum, an expression of concern — is reported in updates without setting it, so read the list rather than the boolean. Its relations field is {} for most papers — is-preprint-of / has-preprint depend on thin publisher deposit — so an absent relation is not a claim that no preprint exists.get_paper_metadata(identifier, follow_published=True) chains a bioRxiv or arXiv preprint to its journal version — arXiv's only when the authors recorded that DOI — but only once OpenAlex has indexed that version; until then the response carries followed_published: false so you can tell you are looking at preprint-era metadata..envMAX_PDF_BYTES | No | Cap on a single PDF download (default 200000000 ≈ 200 MB). none / off / disabled / 0 disables; any other unparseable value (including a negative one) falls back to the default. |
WIKIPEDIA_MAILTO | No | Your email, appended to the Wikipedia User-Agent. A descriptive agent is sent either way, which is what matters: Wikimedia caps an unidentified client at 10 req/min against 200 with a compliant one. |
PDF_CONVERTER | No | PDF-to-markdown backend: mineru (default), marker, or a custom command (see PDF Pipeline) |
PDF_CONVERTER_VENV | No | Path to a virtualenv to activate before running the converter (e.g. ~/.venvs/mineru) |
PDF_CONVERT_TIMEOUT | No | Hard timeout for a single PDF→markdown conversion in seconds (default 1800 = 30 min). Set to none / off / disabled / 0, or any value <= 0, to disable. Unparseable or non-finite falls back to the default. |
PDF_FAST_CONVERTER | No | Backend for convert_paper(mode="fast"): pdftotext (default, needs poppler-utils), pymupdf (needs the fast extra), or a custom command |
PDF_FAST_CONVERT_TIMEOUT | No | Hard timeout for a fast extraction in seconds (default 120). Same disable rules as PDF_CONVERT_TIMEOUT. |
DEBUG_REQUESTS | No | 1 logs every throttled GET to stderr (never stdout — that's the JSON-RPC stream). Re-read per call, so it can be flipped without a restart. |
ENABLE_DEBUG_TOOLS | No | 1 registers a get_server_stats tool exposing per-provider counters and the .env that won at import. Off by default so agents don't see operational data. Read at import — needs a restart. |
{error, retryable: True, backpressure: True}retryable: True, and retry_after_seconds whenever the server advertises Retry-After (either RFC 9110 form), so an agent can branch and pick a wait interval without parsing the message string.retryable: False, because an unclassified failure (a paywalled 403, say) carries no flag either way and a "not marked retryable" test would cache it for the full TTL.User-Agent naming the project and its repository, with a contact address appended when one is set.MAX_PDF_BYTES (default 200 MB) is exceeded. Force-refresh cascades: re-downloading drops the cached markdown + sections so the next conversion picks up the new bytes.busy error with what's running and how long it's been going._count tool so agents can check sizes before fetching. get_paper_references(source="auto") does the survey for you, biased toward Crossref's richer per-entry metadata.author_count, topic_count, total_sections, etc.) so agents don't need follow-up calls to check sizes.suggestion field with recovery guidance (e.g. which search tool to try). One exception: an identifier no provider claims carries its guidance in the error string instead, since there is no provider failure to enrich.