.codex-plugin — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited .codex-plugin (MCP Server) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
<div align="center"> <h1>@cyanheads/openalex-mcp-server</h1> <p><b>Access the OpenAlex academic research catalog — 270M+ publications — via MCP. STDIO or Streamable HTTP.</b> <div>5 Tools • 2 Prompts</div> </p> </div>
<div align="center">
</div>
<div align="center">
</div>
<div align="center">
Public Hosted Server: https://openalex.caseyjhand.com/mcp
</div>
Scholarly catalog data from OpenAlex — 270M+ works, 90M+ authors, 100K+ sources, plus institutions, topics, keywords, publishers, funders, and awards. Search, filter, and aggregate across all nine entity types, resolve ambiguous names to canonical IDs, and walk the citation graph one hop at a time.
| Tool | Description |
|---|---|
openalex_search_entities | Search, filter, sort, or retrieve by ID across all 9 entity types |
openalex_analyze_trends | Group-by aggregation for trend and distribution analysis |
openalex_resolve_name | Resolve a name or an identifier (DOI, ORCID, ROR, PMID, ISSN, OpenAlex ID) to an OpenAlex ID |
openalex_get_citation_graph | Walk the citation graph one hop from a seed work: cites, cited_by, or related_to |
openalex_describe_fields | List valid filter, group_by, and select field names for an entity type |
| Prompt | Description |
|---|---|
openalex_literature_review | Guides a systematic literature search: formulate query, search, filter, analyze citation network, synthesize findings |
openalex_research_landscape | Analyzes the research landscape for a topic: volume trends, top authors/institutions, open access rates, funding sources |
The server also publishes an agent skill, openalex-guide (skill://openalex-guide/SKILL.md, source in skills/openalex-guide/): which filter key answers the question, how to read group_by counts, where each ID type comes from, and how to pin an author without picking a namesake. It ships in the npm package, the Docker image, the .mcpb bundle, and plugin installs.
openalex_search_entities <sub>tool</sub>id: an OpenAlex ID, DOI, ORCID, ROR, PMID, ISSN, or PMCID, bare or as a URL (OpenAlex record-page and API URLs such as https://openalex.org/works/W2741809807 included), or a keyword by its slug or URL. Under entity_type: "funders", a Crossref Funder Registry DOI (10.13039/…) also resolvesid takes precedence: search parameters passed alongside it are dropped, and the response names which onesid with characters past the identifier (W2741809807#x still returns W2741809807), and a funder DOI, which each bill as one list callid_type_mismatch (an OpenAlex ID of another entity type) or id_dot_segment (a . or .. path segment); a ?, #, \, tab, or line break inside an identifier is percent-encoded, so SICI DOIs ending in # resolveentity_type: "awards" reaches grants (G… IDs), which openalex_resolve_name does not look up since OpenAlex has no award autocompleteexact and semantic search modes — semantic ranks a query-dependent candidate set at ~1 req/sec (up to 50 per page), and its meta.count reports the candidate count rather than a match totalfilters accepts a record object or an unambiguous native string ("issn:0920-8542"), including through the filter alias. Conversion is disclosed in notice; commas outside paired double quotes separate clauses, and the first colon separates a key from its value, preserving DOI/URL colons and quoted comma phrases. Exact duplicate keys, bare fragments, and unmatched quotes are rejected with record-form recovery guidance. A record holds one value per key; distinct aliases of the same upstream field still AND together. Set OPENALEX_FILTER_STRING_INPUT=false to require record objects. Native strings apply only to this tool's filters, with the record form remaining the advertised schemaunmatched_quote_in_filter_value); citation filters are checked before the seed lookup. Balanced search phrases such as "graph neural" networks remain valid, including .search.exact. A value sent double-quoted still cannot contain its own interior double quote (quote_in_filter_value)|), AND within a field (+), NOT (!), ranges, comparisons| with an AND (a +, or a space between IDs or codes): OpenAlex would silently drop the AND-ed alternatives, so DE|FR+US and authorships.author.id: A1 A2|A3 fail as mixed_and_or_in_filter_value before any request. Put the AND in a second key instead (institutions.country_code: US beside authorships.institutions.country_code: DE|FR on works)display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, biblio fields) are sent double-quoted, so a value with spaces or commas matches as one phrase (awards.funder_display_name: National Science Foundation). ! and | still work (geo.city: New York|Boston); + cannot AND it with another value.search values and whole-text fields; anywhere else it fails as comma_in_filter_value (use | for OR)+ is a literal character in .search values, DOIs, dates, URLs, exactly matched names (C++, Erasmus+), biblio fields (issue 1+2), and awards.funder_award_idquote_in_filter_valuefilters:country_code, country_codes, or countries): ISO alpha-3 codes, numeric codes, and English names (ISO's or OpenAlex's own facet labels, such as Ivory Coast) become alpha-2 (DEU, 276, Germany → DE; UK → GB). A value that cannot be a country (Germanyy, 999, DE US) fails as invalid_country_code before any requestlanguage: ISO 639-2 codes and English names become ISO 639-1 (eng, English → en; Cebuano → ceb)|/+ segment converts on its own (South Korea|Japan → KR|JP); anything else (null, XK, zh-cn, the countries/DEU path form) is sent as givenselect returns a curated per-entity-type default unless overridden, or ["*"] for the full record; invalid field names error with the valid set. On works, author_list returns each author in authorship order as {id, display_name, orcid} — about a tenth the size of authorships, which adds institutions and affiliations — and is never part of the default or ["*"]page (1-based) instead, up to 50 per page, and mixing either knob with the wrong search_mode is rejected before the upstream callsample (up to 10,000) plus a deterministic seed for reproducible random sampling; keyword and exact modes only, and never alongside sort or cursor — OpenAlex does not sample a semantic search and refuses a sorted sample, so those combinations are rejected before the upstream call. per_page defaults to min(sample, 100), so a sample of 100 or fewer returns whole in one call; a larger one is walked with page and a seed (sample_page_without_seed without one), up to record 10,000 (sample_page_too_deep past it)per_page records, whole, and an id lookup returns every selected array whole; slice: {field, offset, limit} on an id lookup pages one array by count (limit default 50, clamped to 5,000). A response over 8,000,000 bytes on either surface (structuredContent, or content[] text) fails as response_too_large and names a per_page or slice.limit that fits, or a narrower selectauthorships or author_list entries is flagged as possibly capped, with an id + slice call to retrieve more stored authors. Singleton lookups and snapshots retain at most 5,000 authorships per work; at that ceiling both author fields remain possibly capped on every slice. total counts stored entries rather than a complete byline, and next becomes null when those entries are exhausted. Another OpenAlex fetch or snapshot cannot recover authors beyond the storage ceilingnotice, a log, and a counter: per_page above 100 (200 on analysis, 50 under semantic search), slice.limit above 5,000, and sample above 10,000. Set OPENALEX_CLAMP_PER_PAGE=false to reject each over its cap instead. Zero, negatives, and fractions always reject. An id ignores page sizedisplay_name is nullable for untitled records; every call reports OpenAlex daily-budget cost and remaining balanceopenalex_analyze_trends <sub>tool</sub>filters to scope the population before aggregationorder: "count" (default) returns the top-N by count with no further pages; order: "key" enumerates distinct values key-ascending with cursor pagination. Boolean fields keep count order even with "key" and do not support cursor pagination. A supplied cursor selects key order even when order is omitted or "count". OpenAlex can wrap a key-ordered walk to earlier groups: stop when a page's first key repeats an earlier key, or when a page is short or empty, even if next_cursor is still presentinclude_unknown (default false) adds a group for entities with no value for the grouped field, flagged is_unknown: true and labeled as unknown in the text output. The native :include_unknown suffix on group_by also enables it, even with an explicit false; passing both adds the suffix once. OpenAlex keys it -111/-111.0 (numeric fields), unknown (text fields and order: "key"), or an ID ending in /unknown; the key is a sentinel, not a filter value. Boolean fields have no such group — a missing value counts as falsekey_display_name remains present but can be null when OpenAlex supplies no label; text output renders the group key in that case, including unknown buckets.search operators, from_*/to_* range modifiers, decimal scores such as fwci, display_name, external-ID fields such as doi, and fields OpenAlex looks up from a related entity such as primary_location.source.country_code are rejected; openalex_describe_fields(entity_type, "group_by") lists the fields that groupauthorships.countries says in its notice that OpenAlex counts only each work's first 100 authorships for that field, and names authorships.institutions.country_code as the field that counts all of thementity_type: "awards" groups by fields such as start_year, funder.id, or funding_type; groups count awards and do not sum amount_usdopenalex_resolve_name <sub>tool</sub>G…, bare or as an OpenAlex URL) sends no request: OpenAlex has no award autocomplete, so the response is empty with a notice naming the openalex_search_entities call that opens the award. Under an explicit entity_type, a bare G… is searched as a name of that typeentity_type (or any other type) all three are looked up in parallel as unbilled singleton fetches and every record found is returned, institution first; entity_type institutions, funders, or publishers narrows it to that one. A Crossref Funder Registry DOI (10.13039/…) resolves to the funder unless entity_type is works. A PMCID is recognized but resolves nothing — OpenAlex indexes nonep53 reads as publisher P53), so a bare OpenAlex ID that resolves nothing is searched as a name next, under entity_type when given, and the notice names both outcomes; a real ID still resolves with its one request. Under entity_type: "keywords" the shape is searched as a keyword name from the startfilters narrows autocomplete only and requires entity_type — a name query passing filters without it is rejected before any request (filters_without_entity_type). On an identifier lookup they're ignored and named in a notice; a name search that matches nothing under filters echoes them and suggests loosening them firstentity_type set, OpenAlex autocomplete fails on a name over 1,000 characters; that failure is reported once as query_too_long with advice to shorten the name, not retried as an outageopenalex_get_citation_graph <sub>tool</sub>direction sets the edge: cites (works citing the seed), cited_by (the seed's own reference list), related_to (OpenAlex's algorithmic related works, ~8-30 typical, may be empty)seed_id accepts an OpenAlex work ID (bare, or its OpenAlex record-page or API URL), DOI, or PMID (PMCID recognized but resolves nothing); validated against a live lookup first, so a non-existent seed fails as NotFound rather than returning an empty graph, and the ID of another entity type fails as id_type_mismatch before any upstream callfilters/sort/select (author_list included) to narrow the graph; filters cannot set cites/cited_by/related_to, nor an alias of one such as cited_works — those keys are reserved for directionper_page works, wholeresponse_too_large failure (naming a per_page that fits) and 100-authorship cap disclosure as openalex_search_entitiesopenalex_describe_fields <sub>tool</sub>filter, group_by, select) — the complete pool, never truncatedgroup_by is the filter set minus what OpenAlex rejects as an aggregation key: raw date fields, .search/.search.exact operators, from_*/to_* range modifiers, and a per-entity-type set found by grouping every listed field against the live API — decimal scores such as fwci, several source year fields, display_name, and external-ID fields among themcreated_date, updated_date, and their from_/to_ forms are listed filters that OpenAlex serves only on a paid plan; without one, a filter on them fails on its first request as upstream_plan_required, with no retryquery reorders results by name similarity without dropping any field — a nested value's parent object stays reachable further down the listopenalex_literature_review <sub>prompt</sub>topic required; scope (narrow / broad) optional, defaults to narrowscope changes the search step: narrow favors exact search with tight topic filters; broad adds semantic search across multiple related topic IDsopenalex_research_landscape <sub>prompt</sub>topic requiredfunders.id, which counts every work that lists a funder and labels each group with the funder's name; for grants it groups the topic's works by awards.id and opens the top G… IDs on entity_type: "awards" for amounts and dates, since few awards carry a topic of their ownBuilt on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
OpenAlex-specific:
http, https, or no scheme, PubMed/PubMed Central URLs); a PMCID normalizes but resolves nothing since OpenAlex indexes nonemailto identifies the caller to OpenAlex's polite poolAbortSignalAgent-friendly output:
budget.costUsd, remainingUsd, resetsInSeconds)entity_not_found, upstream_budget_exhausted, semantic_per_page_cap, reserved_filter_key, and more) each carrying an explicit recovery hintdisplay_name stays null for untitled or paratext records instead of being backfilled<?CDATA …?> math markers are unwrapped with their TeX kept, MathType equation blobs (% MathType!MTEF!…!44A1!) are removed while the TeX that restates them stays, and a double-escaped line break (a literal \n) becomes a space; literal text such as A < B, 95% CI, and TeX such as \nu is kept. Identifier and URL fields (id, doi, ids, orcid, ror, issn, *_url, lineages) are returned exactly as OpenAlex stores them, so each one resolves when passed back. The text output escapes that text for Markdown, so a stray *, <tag>, or [x](y) renders as written, and line breaks, tabs, and leading or trailing spaces read back as the structured output holds them; a multi-line display_name renders on its own **Display Name:** line. Notices escape caller text the same way. IDs, and URLs that render as links, stay byte-identicalConnect to the hosted instance, no install:
claude mcp add --transport http openalex-mcp-server https://openalex.caseyjhand.com/mcpOr run it locally:
claude mcp add openalex-mcp-server -- npx -y @cyanheads/openalex-mcp-serverFor other clients, add this to the MCP config file:
{
"mcpServers": {
"openalex-mcp-server": {
"command": "npx",
"args": ["-y", "@cyanheads/openalex-mcp-server"]
}
}
}An optional OPENALEX_API_KEY — a free OpenAlex account key — raises the rate limits and daily budget over anonymous access; OPENALEX_MAILTO identifies you to OpenAlex's polite pool. For Docker or a source build, see Running the server.
| Variable | Description | Default |
|---|---|---|
MCP_TRANSPORT_TYPE | Transport: stdio or http. | stdio |
MCP_HTTP_PORT | Port for HTTP server. | 3010 |
MCP_SESSION_MODE | HTTP session mode: stateless, stateful, or auto (resolves to stateful). The server declares stateless in code; an explicit value overrides it. | stateless |
MCP_AUTH_MODE | Auth mode: none, jwt, or oauth. | none |
MCP_ALLOWED_ORIGINS | Comma-separated allow-list of browser Origin headers for HTTP transport. Unset = loopback-only; set to * to disable. | _loopback only_ |
MCP_LOG_LEVEL | Log level (RFC 5424). | debug |
LOGS_DIR | Directory for log files (Node.js only). | <project-root>/logs |
STORAGE_PROVIDER_TYPE | Storage backend. | in-memory |
OPENALEX_API_KEY | OpenAlex account API key, sent upstream as api_key= (free from openalex.org/settings/api). Without it, anonymous rate limits apply. | — |
OPENALEX_MAILTO | Email sent upstream as mailto= to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key. | — |
OPENALEX_BASE_URL | OpenAlex API base URL. | https://api.openalex.org |
OPENALEX_FILTER_STRING_INPUT | Accept unambiguous native filter strings on openalex_search_entities. Set false to require record objects; invalid boolean values fail startup. | true |
OPENALEX_CLAMP_PER_PAGE | Clamp oversized positive integer per_page (100 search/citation, 50 semantic, 200 analysis), slice.limit (5,000), and sample (10,000) with a requested/applied notice. Set false to reject each over its cap; invalid boolean values fail startup. | true |
OTEL_ENABLED | Enable OpenTelemetry instrumentation (spans, metrics, completion logs). | false |
See .env.example for the full list of optional overrides.
Requires Bun v1.4.0 or higher (or Node.js v24+).
git clone https://github.com/cyanheads/openalex-mcp-server.git
cd openalex-mcp-server && bun install
cp .env.example .env # optionally set OPENALEX_API_KEY # One-time build
bun run rebuild
# Run the built server
bun run start:stdio
# or
bun run start:http bun run devcheck # Lints, formats, type-checks
bun run test # Runs the test suitedocker run --rm -e OPENALEX_API_KEY=your-key -p 3010:3010 ghcr.io/cyanheads/openalex-mcp-server:latestThe image defaults to HTTP transport, stateless session mode, and logs to /var/log/openalex-mcp-server. To run it over stdio from an MCP client, use "command": "docker" with "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/openalex-mcp-server:latest"]. To build locally, docker build -t openalex-mcp-server .; OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
| Directory | Purpose |
|---|---|
src/index.ts | createApp() entry point — registers tools and prompts. |
src/config/ | Server-specific environment variable parsing and validation with Zod. |
src/mcp-server/tools/definitions/ | Tool definitions (*.tool.ts). |
src/mcp-server/prompts/definitions/ | Prompt definitions (*.prompt.ts). |
src/services/openalex/ | OpenAlex API client, field catalog, and domain types. |
skills/ | The published openalex-guide agent skill, served over MCP and shipped with the package. |
tests/ | Unit and integration tests, mirroring the src/ structure. |
See CLAUDE.md for development guidelines and architectural rules. The short version:
try/catch in tool logicctx.log for logging, ctx.state for storageopenalex_resolve_name before filtering by entityIssues are welcome. Run checks before submitting:
bun run devcheck
bun run testApache-2.0 — see LICENSE for details.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.