openalex-mcp-server
An MCP server that provides access to the OpenAlex academic research catalog, enabling agents to search, filter, and aggregate data across 270M+ works, 90M+ authors, and other entities. It supports resolving names to IDs, analyzing trends, and walking citation graphs.
Endpoint and access
No sign-in to connect and list tools. Individual tools may still need a vendor account, credentials, or permissions; check the documented prerequisites.
https://openalex.caseyjhand.com/mcpWhat its tools do
5 tools, grouped by the hints the server advertises. Hints are self-declared, not verified behavior.
Checks and signals
Each line is separate evidence, not a trust score.
- Passed Endpoint answered the live check ·
- Availability 1 of 1 checks passed in 30 days
What you can do with it
- Search and filter academic works, authors, and institutions
- Resolve ambiguous names or identifiers to canonical OpenAlex IDs
- Analyze research trends and distribution across entity types
- Walk citation graphs to find citing or cited works
Source and maintenance
- Maintenance checked
Protocol and capabilities
- Server
- openalex-mcp-server 0.11.0
- MCP protocol negotiated in check
- 2025-11-25
- Tools
- 5 advertised
- Resources
- 1 advertised
- Resource templates
- 1 advertised
- Prompts
- 2 advertised
{
"tools": {
"listChanged": true
},
"logging": {},
"prompts": {
"listChanged": true
},
"resources": {
"subscribe": true,
"listChanged": true
},
"extensions": {
"io.modelcontextprotocol/skills": {
"directoryRead": true
}
}
}Server-provided instructions
Untrusted server output shown for inspection. It is not Koskamo guidance and is excluded from knowledge search.
Tools
5 advertised
openalex_resolve_name — Openalex Resolve Name
Resolve a name or an identifier to an OpenAlex ID. ALWAYS use this before filtering by entity — names are ambiguous, IDs are not. A name returns up to 10 autocomplete matches with disambiguation hints. An identifier — OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN, bare or in URL form — resolves directly to the record it addresses instead of running a name search. Short names share the bare OpenAlex ID shape (keyword p53 reads as publisher ID P53), so a bare OpenAlex ID that resolves nothing is searched as a name next. Two identifiers can address more than one entity type: a ROR names an organization's institution, funder, and publisher records alike, so it returns each one that exists (institution first) unless entity_type picks one of those three; and a Crossref Funder Registry DOI (10.13039/…) resolves to the funder unless entity_type is works. A PMCID is recognized as well, bare or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work's PMID or DOI instead. An award ID (G…, bare or as an OpenAlex URL) is not looked up, since OpenAlex has no award autocomplete: it returns no matches and a notice naming the openalex_search_entities call that opens the award. Under entity_type, a bare G… is searched as a name of that type instead.
{
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"query"
],
"properties": {
"query": {
"type": "string",
"minLength": 1,
"description": "Name or partial name to resolve. Also accepts an identifier, bare or in URL form — OpenAlex ID (\"W2741809807\", \"F4320332161\", or an OpenAlex record-page or API URL such as \"https://openalex.org/works/W2741809807\"), DOI (\"10.1038/nature12373\", or a funder's Funder Registry DOI such as \"10.13039/100000001\"), ORCID (\"0000-0002-1825-0097\"), ROR (\"https://ror.org/00hx57361\"), PMID (\"12345678\" or \"https://pubmed.ncbi.nlm.nih.gov/12345678\"), ISSN (\"1234-5678\") — which resolves straight to the record it addresses, or for a ROR up to three (see entity_type), instead of running a name search. A bare OpenAlex ID that resolves nothing is then searched as a name — under entity_type when given, and not at all when filters come without entity_type — since short names share its shape (\"P53\" is a keyword); under entity_type keywords it is searched as a name from the start. A keyword URL (\"https://openalex.org/keywords/groundwater\") resolves the same way; a bare keyword slug reads as a name and runs a name search, which finds it too. A PMCID (\"PMC1234567\" or a PubMed Central URL) is recognized but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work's PMID or DOI instead. An award ID (\"G417894356\", or \"https://openalex.org/awards/G417894356\") sends no request and names the openalex_search_entities call for it; with entity_type, a bare one is searched as a name of that type."
},
"filters": {
"type": "object",
"description": "Narrow autocomplete results with a filter record object, using the openalex_search_entities value syntax; native string input is unavailable here. Example: restrict to a specific country or publication year range. Country fields take ISO 3166-1 alpha-2 codes ({\"country_code\": \"DE\"} on institutions) and `language` takes ISO 639-1 codes (\"en\"); alpha-3, numeric, ISO 639-2, and English-name forms are converted — a multi-word name is one value, so \"South Korea|Japan\" ORs two countries — and a value that cannot be a country code is rejected. A multi-word value on a field matched as whole text (display_name, geo.city, geo.region, awards.funder_display_name) matches as one phrase ({\"geo.city\": \"New York\"} on institutions; a comma, as in \"University of California, Berkeley\", is part of it, and it cannot contain a double quote of its own), \"!\" and \"|\" included, but cannot be ANDed with \"+\" in the same field. A value sent unquoted must have paired double quotes; balanced search phrases remain valid. A value mixing \"|\" with an AND operator — a \"+\", or a space on a field where words mean AND — is rejected, since OpenAlex drops every \"|\" alternative that ANDs more than one value; put the AND in a second key instead. Requires entity_type — filter fields belong to one entity type, so a name search across every type takes none, and a name query passing filters without entity_type is rejected. Applies to name queries only — an identifier lookup returns the records it addresses regardless.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"entity_type": {
"enum": [
"works",
"authors",
"sources",
"institutions",
"topics",
"keywords",
"publishers",
"funders"
],
"type": "string",
"description": "Entity type to search. Awards are not among them: OpenAlex has no award autocomplete, so search awards with openalex_search_entities. Omit for cross-entity search (useful when entity type is unknown). With an identifier `query` it narrows only an identifier that can address more than one type: a ROR to institutions, funders, or publishers (omit it to get every one of the three that exists), and a Funder Registry DOI (10.13039/…) to works instead of the funder. Every other identifier resolves as the type it belongs to, whatever this names — except under keywords, where a bare OpenAlex ID shape (\"P53\") is searched as a keyword name, since keyword IDs are slugs."
}
},
"additionalProperties": false
}{
"type": "object",
"anyOf": [
{
"not": {
"required": [
"error"
]
},
"required": [
"results"
]
},
{
"required": [
"error"
]
}
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"error": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "JSON-RPC error code for this failure."
},
"data": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"examples": [
"unmatched_quote_in_filter_value",
"filters_without_entity_type",
"rate_limited",
"upstream_budget_exhausted",
"upstream_plan_required",
"upstream_timeout",
"upstream_unavailable",
"upstream_unauthorized",
"upstream_forbidden",
"id_dot_segment",
"comma_in_filter_value",
"invalid_country_code",
"mixed_and_or_in_filter_value",
"quote_in_filter_value",
"upstream_invalid_params",
"upstream_invalid_id_value",
"query_too_long",
"upstream_invalid_params_other",
"upstream_validation_failed"
],
"description": "Machine-readable failure mode. Declared by this tool: `unmatched_quote_in_filter_value`: A filter value sent unquoted has an unmatched double quote. `filters_without_entity_type`: A name `query` passed `filters` without `entity_type`. Filter fields belong to one entity type, and a name search across every type takes no filters. `rate_limited`: OpenAlex throttled the autocomplete request for exceeding its per-second ceiling (HTTP 429). `upstream_budget_exhausted`: The OpenAlex daily usage budget is spent (HTTP 429). `upstream_plan_required`: A filter is one OpenAlex serves only on a paid plan — created_date, updated_date, or a from_/to_ form of either (HTTP 429 \"Plan upgrade required\"). `upstream_timeout`: OpenAlex did not respond within the request deadline. `upstream_unavailable`: OpenAlex was unreachable or unusable — HTTP 503, a connection failure, or a body that was empty, HTML, or unparseable JSON. `upstream_unauthorized`: OpenAlex rejected the API key (HTTP 401). `upstream_forbidden`: OpenAlex denied access to autocomplete (HTTP 403). `id_dot_segment`: An identifier `query` holds a `.` or `..` path segment, written plainly or percent-encoded. `comma_in_filter_value`: A `filters` value contains a comma, which collides with the OpenAlex filter separator, on a field that takes no phrase. Free-text search filters (any `.search` key, such as display_name.search) and fields matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, parsed_longest_name, biblio fields) are exempt: a comma there is sent quoted, as part of the phrase. `invalid_country_code`: A country filter (a key ending in country_code, country_codes, or countries) holds a segment that cannot be a country code, such as a misspelled name, an unassigned number, \"unknown\", or two codes joined by a space (\"DE US\"). `mixed_and_or_in_filter_value`: A `filters` value puts \"|\" (OR) beside an AND operator — a \"+\", or a space on a field where words mean AND (on a country field a space is always part of a country name, and inside a language name it is part of the name). OpenAlex splits the value on \"|\" first and drops every alternative that ANDs more than one value, so no single value can carry both. `quote_in_filter_value`: A `filters` value sent to OpenAlex double-quoted holds a double quote inside its outer pair — a value the server quotes (a multi-word value on a field matched as whole text, or a value holding a comma on such a field or on a free-text search filter), or one the caller wrapped in double quotes on any filter but a free-text search. `upstream_invalid_params`: OpenAlex rejected an invalid filter field name on the autocomplete query (HTTP 400). `upstream_invalid_id_value`: A `filters` entry expecting an entity ID received a value that is not an OpenAlex ID — usually a name (HTTP 400). `query_too_long`: OpenAlex autocomplete failed (HTTP 500) on a `query` longer than the 1,000 characters it accepts when entity_type is set. `upstream_invalid_params_other`: OpenAlex rejected the autocomplete request (HTTP 400) for a reason other than an invalid field name. `upstream_validation_failed`: OpenAlex rejected the autocomplete request as semantically invalid (HTTP 422). Other values are possible when a failure originates below the handler."
},
"recovery": {
"type": "object",
"required": [
"hint"
],
"properties": {
"hint": {
"type": "string"
}
},
"description": "Actionable next step for the caller.",
"additionalProperties": {}
},
"retryable": {
"type": "boolean",
"description": "Whether retrying may succeed."
}
},
"additionalProperties": {}
},
"message": {
"type": "string",
"description": "Human-readable description of what went wrong."
}
},
"description": "Present when the call failed. Absent on success.",
"additionalProperties": {}
},
"budget": {
"type": "object",
"required": [
"costUsd",
"remainingUsd",
"resetsInSeconds"
],
"properties": {
"costUsd": {
"type": "number",
"description": "USD this call spent. Autocomplete is priced at the floor — resolving a name before filtering costs far less than the failed searches an ambiguous name causes."
},
"remainingUsd": {
"type": "number",
"description": "USD left in today's OpenAlex budget after this call."
},
"resetsInSeconds": {
"type": "number",
"description": "Seconds until the daily budget refills (midnight UTC)."
},
"prepaidRemainingUsd": {
"type": "number",
"description": "USD left in the prepaid balance — a separate pool OpenAlex draws on only after the daily allowance runs out, and which the daily reset does not refill. Add it to `remainingUsd` for the full spendable amount. Absent when the account holds no prepaid balance."
}
},
"description": "What this call cost against the OpenAlex daily budget and what is left of it. Read `remainingUsd` here to size the search or traversal this resolution feeds. Absent when OpenAlex omitted the accounting headers.",
"additionalProperties": false
},
"notice": {
"type": "string",
"description": "Guidance notice. Set when nothing matched (echoes the query, and the filters when a name search applied them, and suggests corrections), when a bare OpenAlex ID resolved nothing and the query was searched as a name instead (names both outcomes, or why the name search did not run or finish), when an identifier query was passed name-search parameters that do not apply to it, when one of the lookups for a ROR failed upstream (names the entity type whose record may be missing), or when the query is an award ID (names the openalex_search_entities call that opens it). Caller text in it is JSON-encoded and Markdown-escaped, the same string on both surfaces. Absent otherwise."
},
"results": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"external_id",
"display_name",
"entity_type",
"cited_by_count",
"works_count",
"hint"
],
"properties": {
"id": {
"type": "string",
"description": "OpenAlex ID."
},
"hint": {
"type": [
"string",
"null"
],
"description": "Disambiguation context as plain text — last institution (authors), host organization (sources), place or country (institutions); author names (works) from a name search, publication year from an identifier lookup. null when the record carries none."
},
"entity_type": {
"type": "string",
"description": "Entity type — one of: work, author, source, institution, topic, keyword, publisher, funder."
},
"external_id": {
"type": [
"string",
"null"
],
"description": "Canonical external ID (DOI, ORCID, ROR, ISSN)."
},
"works_count": {
"type": [
"number",
"null"
],
"description": "Associated works. null for works themselves."
},
"display_name": {
"type": [
"string",
"null"
],
"description": "Human-readable name as plain text, with HTML entities decoded and markup removed. null only for an identifier lookup that landed on a record OpenAlex holds no title for (paratext works and other untitled entries) — use `id` to identify it."
},
"cited_by_count": {
"type": "number",
"description": "Citation count (direct for works, aggregate for others)."
}
},
"description": "A single autocomplete match with its ID, name, entity type, activity stats, and a disambiguation hint.",
"additionalProperties": false
},
"description": "Matches: up to 10 autocomplete matches for a name; for an identifier, the record it addresses — for a ROR, up to three, institution then funder then publisher — or none."
}
},
"additionalProperties": false
}{
"readOnlyHint": true,
"openWorldHint": true,
"idempotentHint": true
}openalex_search_entities — Openalex Search Entities
Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders, awards). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Filters take a record object; an unambiguous native string such as "issn:0920-8542" is also accepted when enabled and disclosed in `notice`. Native clauses split on commas outside paired double quotes and the first colon; duplicate keys and bare fragments are rejected. Any filter value sent unquoted must have paired double quotes; balanced search phrases remain valid. Supports keyword search with boolean operators, exact phrase matching, AI semantic search, and seeded random samples. Use openalex_resolve_name to resolve names to IDs before filtering. Searches and ID lookups return a curated set of fields by default; pass `select` to override with specific fields, or `["*"]` for the full record. A page holds `per_page` records and `next_cursor` continues it — semantic search and a seeded `sample` continue with `page` instead; an `id` lookup returns every selected array whole, and `slice` pages one array by count.
{
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"entity_type"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"description": "Retrieve a single entity by ID. Supports: OpenAlex ID (\"W2741809807\", or its OpenAlex record-page or API URL such as \"https://openalex.org/works/W2741809807\" or \"https://api.openalex.org/works/W2741809807\"), whose letter must name `entity_type` — W works, A authors, S sources, I institutions, T topics, P publishers, F funders, G awards; DOI (\"10.1038/nature12373\"; under entity_type \"funders\", a funder's Crossref Funder Registry DOI such as \"10.13039/100000001\"), ORCID (\"0000-0002-1825-0097\"), ROR (\"https://ror.org/00hx57361\" — one ROR can name an institution, a funder, and a publisher, and `entity_type` picks which), PMID (\"12345678\" or \"https://pubmed.ncbi.nlm.nih.gov/12345678\"), ISSN (\"1234-5678\"). Keywords are identified by slug rather than a native ID — pass either the slug (\"groundwater\") or the URL a search returns (\"https://openalex.org/keywords/groundwater\"). A PMCID is recognized too, bare (\"PMC1234567\") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work's PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `[\"*\"]` for the complete record). Every selected array returns whole; `slice` pages a long one by count. To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown."
},
"page": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": 1,
"description": "Page number (1-based) for semantic search and for a seeded `sample`, the two searches that paginate with `page` instead of `cursor`. Semantic search ranks a query-dependent candidate set whose size `meta.count` reports, so the last reachable page is ceil(meta.count / per_page) — e.g. page 14 for a count of 70 with per_page=5. A sample draws min(sample, meta.count) records, so its last page is ceil(min(sample, meta.count) / per_page); paging a sample requires `seed`, and OpenAlex serves only its first 10,000 records, so a page × per_page above 10,000 is rejected. Passing it with any other search is rejected."
},
"seed": {
"type": "string",
"description": "Deterministic seed for `sample`. Same seed + same filters = the same draw, page for page — required to walk a sample larger than one page with `page`, and worth passing whenever reproducibility matters. Has no effect without `sample`, and a search that passes it alone is rejected."
},
"sort": {
"type": "string",
"description": "Sort field. Prefix with \"-\" for descending. Comma-separate for a multi-key sort, applied left to right, with the \"-\" prefix set per key (\"-publication_year,cited_by_count\" sorts by year descending, then citations ascending). Common: \"cited_by_count\", \"-publication_date\", \"-relevance_score\" (default when query present). Note: when combined with a keyword query, an explicit sort overrides relevance ranking entirely — top results may be highly cited but only tangentially on-topic. Use \"-relevance_score\" or omit sort to keep the most relevant results first. \"-relevance_score\" requires an active search via \"query\" or a \"filter:search\" filter — passing it without one will fail. Not combinable with `sample` — a search passing both is rejected."
},
"query": {
"type": "string",
"minLength": 1,
"description": "Text search query. Supports boolean operators (AND, OR, NOT), quoted phrases (\"exact match\"), wildcards (machin*), fuzzy matching (machin~1), and proximity (\"climate change\"~5). Omit for filter-only queries — an empty string is rejected, since a blank search is a mistake rather than a request for the whole catalog."
},
"slice": {
"type": "object",
"required": [
"field"
],
"properties": {
"field": {
"type": "string",
"description": "Top-level array field to page, such as \"authorships\", \"author_list\", or \"referenced_works\". Leave it empty to skip slicing."
},
"limit": {
"type": "integer",
"default": 50,
"minimum": 1,
"description": "Elements to return. Default 50; a larger value returns at most 5,000 and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false)."
},
"offset": {
"type": "integer",
"default": 0,
"maximum": 9007199254740991,
"minimum": 0,
"description": "0-based index of the first element to return."
}
},
"description": "Page one array of the record at `id`: returns `id`, `display_name`, and `limit` elements of `field` from `offset` — fewer only at the array end — with the window in `windows`, whose `next` call repeats `limit` from the following offset. Requires `id`; replaces `select`. An offset at or past the end returns an empty window. A 100-author list cap can be continued on the singleton; at 5,000 stored authors the byline remains possibly capped, but `next` is null once stored entries are exhausted."
},
"cursor": {
"type": "string",
"minLength": 1,
"description": "Pagination cursor from a previous response. Pass to get the next page. Omit it on the first call — an empty string is rejected, since a supplied-but-blank cursor is a caller mistake rather than a request for page 1. Keyword and exact modes only — semantic search walks its candidates with `page`, and a `cursor` sent with it is rejected."
},
"sample": {
"type": "integer",
"minimum": 1,
"description": "Return a random sample of this many entities matching the filters, up to 10,000; a larger value draws 10,000 and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false). The sample comes back `per_page` records at a time — by default the whole sample up to 100 — and a larger one is walked with `page` and a `seed`, which fixes the draw; without `seed` only its first page is returned. `meta.count` stays the population matching the filters. Keyword and exact modes only: OpenAlex does not sample a semantic search, so `sample` with search_mode \"semantic\" is rejected. Cannot be combined with `cursor` or `sort` — a sample has no order, and a search passing either is rejected. Useful for unbiased exploration: spot-checking filter correctness, stratified review prompts, or generating exploration sets without bias toward most-cited."
},
"select": {
"type": "array",
"items": {
"type": "string"
},
"description": "OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `[\"*\"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. On sources, `publisher` returns `host_organization_name`: the publisher of a journal, the hosting institution of a repository, and null on most sources. On works, `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `[\"*\"]` includes it. Invalid field names produce an error identifying the rejected field. A wide projection on a large page makes a large response, so name the fields you need. Not applied with `slice`. Example: [\"doi\", \"author_list\", \"primary_topic\"]."
},
"filters": {
"type": "object",
"description": "Filter criteria as field:value pairs. AND across fields (multiple keys). OR within field: pipe-separate (\"us|gb\"). NOT: prefix \"!\" (\"!us\"). Range: \"2020-2024\". Comparison: \">100\", \"<50\". AND within same field: \"+\"-separate (\"us+gb\"); inside a `.search` value, a DOI, a date (a timestamp's \"+00:00\" offset), a URL field such as landing_page_url, an exactly matched name (display_name, awards.funder_display_name — \"C++\", \"Erasmus+\"), a biblio field (issue \"1+2\"), or awards.funder_award_id, \"+\" is a literal character. A value cannot mix \"|\" with an AND operator — a \"+\", or a space between values on an ID, code, or `language` key (a space inside a language name is part of the name, and a country field never reads a space as AND) — OpenAlex splits it on \"|\" first and drops every alternative that ANDs more than one value, so such a value is rejected; put the AND in a second key instead (\"institutions.country_code\": \"US\" beside \"authorships.institutions.country_code\": \"DE|FR\" on works). On a field matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, biblio fields), a space is part of the value: \"New York\", \"!New York\", and \"New York|Boston\" match as written, but quotes around one \"|\" alternative make that alternative match nothing. A comma is part of such a value too (\"University of California, Berkeley\" on raw_affiliation_strings): a value holding one is sent as one quoted phrase, its \"+\" a literal character. A value sent as a quoted phrase cannot contain a double quote of its own. A multi-word value there cannot be ANDed with another (\"New York+Boston\" matches nothing); to require two funders or institutions, AND their IDs (awards.funder_id, authorships.institutions.id). Country fields (keys ending in country_code, country_codes, or countries) take ISO 3166-1 alpha-2 codes (\"DE\"): an alpha-3 code (\"DEU\"), numeric code (\"276\"), or English name (\"Germany\", \"South Korea\") is converted, \"UK\" becomes \"GB\", and a value that cannot be a country code is rejected. A space there is part of a name, never a separator: \"South Korea|Japan\" ORs two countries, and \"DE US\" is rejected. `language` takes ISO 639-1 codes (\"en\"): an ISO 639-2 code (\"eng\") or English name (\"English\") is converted. Two keys that resolve to the same upstream field (an alias and its canonical name, e.g. `year` and `publication_year`) are both applied and AND'd, so they narrow rather than override each other. Use OpenAlex IDs (not names) for entity filters — resolve names first. Common keys: `openalex` (filter by entity ID, e.g. {\"openalex\": \"W123|W456\"}), `cites` (works citing a given work), `publication_year` (range \"2020-2024\"), `authorships.author.id`, `type`, `is_oa`.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"per_page": {
"type": "integer",
"minimum": 1,
"description": "Results per page. Default 25, or under `sample` the sample size up to 100, so a sample of 100 or fewer returns whole in one call. A larger value returns at most 100 and says so — at most 50 under search_mode \"semantic\", which OpenAlex also limits to about 1 request per second. A server set to OPENALEX_CLAMP_PER_PAGE=false rejects a value over either cap instead. An `id` lookup ignores page size."
},
"entity_type": {
"enum": [
"works",
"authors",
"sources",
"institutions",
"topics",
"keywords",
"publishers",
"funders",
"awards"
],
"type": "string",
"description": "Type of scholarly entity to search."
},
"search_mode": {
"enum": [
"keyword",
"exact",
"semantic"
],
"type": "string",
"default": "keyword",
"description": "Search strategy. \"keyword\": stemmed full-text (default). \"exact\": no stemming, matches individual words (use quoted phrases for multi-word exact match). \"semantic\": AI embedding similarity over a query-dependent candidate set whose size `meta.count` reports, at ~1 req/sec, up to 50 per page, and paginated with `page` rather than `cursor`."
}
},
"additionalProperties": false
}{
"type": "object",
"anyOf": [
{
"not": {
"required": [
"error"
]
},
"required": [
"meta",
"results",
"echo",
"totalCount"
]
},
{
"required": [
"error"
]
}
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"echo": {
"type": "string",
"description": "Compact echo of the criteria that actually ran (entity_type, query, filters, sort, search_mode) — surfaces what was searched when results are empty. An `id` lookup echoes entity_type and id alone, because the search criteria are not applied on that path."
},
"meta": {
"type": "object",
"required": [
"count",
"per_page",
"next_cursor"
],
"properties": {
"count": {
"type": "number",
"description": "Total results matching the query/filters — under `sample` too, whose draw holds min(sample, count) records. Under search_mode \"semantic\" it is instead the size of the ranked candidate set — the most results `page` can reach — not an exhaustive match total."
},
"per_page": {
"type": "number",
"description": "Page size OpenAlex echoed for this request — the applied per_page after clamping or its default, not the number of records returned. A singleton reports 1. A short or exhausted page carries fewer records than this."
},
"next_cursor": {
"type": [
"string",
"null"
],
"description": "Cursor for the next page; pass it back unchanged as `cursor`. null when no further page exists — always under semantic search and `sample`, which continue with `page` — except on a final page that exactly fills `per_page`: OpenAlex still returns a cursor there, and it leads to an empty page."
}
},
"description": "Result metadata including pagination.",
"additionalProperties": false
},
"error": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "JSON-RPC error code for this failure."
},
"data": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"examples": [
"invalid_filter_string",
"duplicate_filter_string_key",
"unmatched_quote_in_filter_value",
"semantic_per_page_cap",
"response_too_large",
"semantic_without_query",
"semantic_with_cursor",
"page_without_semantic",
"sample_with_cursor",
"sample_page_without_seed",
"sample_page_too_deep",
"sample_with_semantic",
"sample_with_sort",
"seed_without_sample",
"slice_without_id",
"slice_field_not_array",
"id_type_mismatch",
"id_dot_segment",
"entity_not_found",
"rate_limited",
"upstream_budget_exhausted",
"upstream_plan_required",
"upstream_timeout",
"upstream_unavailable",
"upstream_unauthorized",
"upstream_forbidden",
"comma_in_filter_value",
"invalid_country_code",
"mixed_and_or_in_filter_value",
"quote_in_filter_value",
"upstream_invalid_params",
"upstream_invalid_id_value",
"upstream_sort_requires_search",
"query_too_long",
"upstream_invalid_params_other",
"upstream_validation_failed"
],
"description": "Machine-readable failure mode. Declared by this tool: `invalid_filter_string`: A native filter string has an incomplete clause or unmatched double quotes. `duplicate_filter_string_key`: A native filter string repeats an exact key. `unmatched_quote_in_filter_value`: A filter value sent unquoted has an unmatched double quote. `semantic_per_page_cap`: A semantic search (no `id`) set per_page above its cap of 50 on a server with OPENALEX_CLAMP_PER_PAGE=false; with clamping on (the default), it returns 50 and says so. `response_too_large`: The response's structuredContent or content[] text would exceed 8,000,000 bytes — a wide `select` on a large page, or a record with very long arrays. A page is never shortened to fit. `semantic_without_query`: A search set search_mode to \"semantic\" without supplying the `query` it embeds. `semantic_with_cursor`: A search set search_mode to \"semantic\" and supplied `cursor`, which OpenAlex rejects on a semantic query. `page_without_semantic`: A search supplied `page` with neither search_mode \"semantic\" nor `sample`. `sample_with_cursor`: A search (no `id`) provided both `sample` and `cursor`. `sample_page_without_seed`: A search (no `id`) provided `page` with `sample` but no `seed`, which OpenAlex refuses: an unseeded sample redraws on every call, so its pages would overlap. `sample_page_too_deep`: A sample page reaches past record 10,000 (`page` × `per_page` above 10,000), the most of a sample OpenAlex serves by page number. `sample_with_semantic`: A search (no `id`) provided `sample` with search_mode \"semantic\", which OpenAlex does not sample — it returns the same ranked candidates under every seed. `sample_with_sort`: A search (no `id`) provided both `sample` and `sort`, which OpenAlex refuses together. `seed_without_sample`: A search (no `id`) provided `seed` without `sample`. `slice_without_id`: A search passed `slice` without `id` — slice pages one array of one record. `slice_field_not_array`: The `slice` field is `*`, or is not an array on the record at `id` — absent, null, a scalar, or an object. `id_type_mismatch`: `id` is a native OpenAlex ID whose letter names another entity type than `entity_type`, or a concept ID (C…), a type this server does not serve. `id_dot_segment`: `id` holds a `.` or `..` path segment, written plainly or percent-encoded. `entity_not_found`: Lookup by id matched no OpenAlex entity. `rate_limited`: OpenAlex throttled the request for exceeding its per-second ceiling (HTTP 429). `upstream_budget_exhausted`: The OpenAlex daily usage budget is spent (HTTP 429). `upstream_plan_required`: A filter is one OpenAlex serves only on a paid plan — created_date, updated_date, or a from_/to_ form of either (HTTP 429 \"Plan upgrade required\"). `upstream_timeout`: OpenAlex did not respond within the request deadline. `upstream_unavailable`: OpenAlex was unreachable or unusable — HTTP 503, a connection failure, a body that was empty, HTML, or unparseable JSON, or a semantic search whose query embedding failed upstream (HTTP 400 `Failed to embed query:`, or semantic search not configured). `upstream_unauthorized`: OpenAlex rejected the API key (HTTP 401). `upstream_forbidden`: OpenAlex denied access to the requested resource (HTTP 403). `comma_in_filter_value`: A filter value contains a comma, which collides with the OpenAlex filter separator, on a field that takes no phrase. Free-text search filters (any `.search` key, such as title.search or abstract.search.exact) and fields matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, parsed_longest_name, biblio fields) are exempt: a comma there is sent quoted, as part of the phrase. `invalid_country_code`: A country filter (a key ending in country_code, country_codes, or countries) holds a segment that cannot be a country code, such as a misspelled name, an unassigned number, \"unknown\", or two codes joined by a space (\"DE US\"). `mixed_and_or_in_filter_value`: A filter value puts \"|\" (OR) beside an AND operator — a \"+\", or a space on a field where words mean AND (on a country field a space is always part of a country name, and inside a language name it is part of the name). OpenAlex splits the value on \"|\" first and drops every alternative that ANDs more than one value, so no single value can carry both. `quote_in_filter_value`: A filter value sent to OpenAlex double-quoted holds a double quote inside its outer pair — a value the server quotes (a multi-word value on a field matched as whole text, or a value holding a comma on such a field or on a free-text search filter), or one the caller wrapped in double quotes on any filter but a free-text search. `upstream_invalid_params`: OpenAlex rejected an invalid filter, select, or sort field name (HTTP 400). `upstream_invalid_id_value`: An entity-ID filter received a value that is not an OpenAlex ID — usually a name (HTTP 400). `upstream_sort_requires_search`: sort=-relevance_score was used without an active search (HTTP 400). `query_too_long`: OpenAlex rejected `query` as longer than the search length it accepts (HTTP 400). `upstream_invalid_params_other`: OpenAlex rejected the request (HTTP 400) for a reason other than an invalid field name. `upstream_validation_failed`: OpenAlex rejected the request as semantically invalid (HTTP 422). Other values are possible when a failure originates below the handler."
},
"recovery": {
"type": "object",
"required": [
"hint"
],
"properties": {
"hint": {
"type": "string"
}
},
"description": "Actionable next step for the caller.",
"additionalProperties": {}
},
"retryable": {
"type": "boolean",
"description": "Whether retrying may succeed."
}
},
"additionalProperties": {}
},
"message": {
"type": "string",
"description": "Human-readable description of what went wrong."
}
},
"description": "Present when the call failed. Absent on success.",
"additionalProperties": {}
},
"budget": {
"type": "object",
"required": [
"costUsd",
"remainingUsd",
"resetsInSeconds"
],
"properties": {
"costUsd": {
"type": "number",
"description": "USD this call spent. 0 for an `id` lookup by an OpenAlex ID, DOI, ORCID, ROR, PMID, or ISSN — OpenAlex does not bill those single-entity fetches, so batching known IDs beats paging a filtered list. OpenAlex bills like one list request a keyword looked up by its slug, an `id` carrying characters past the identifier itself (`W2741809807#x` still returns W2741809807), and a funder looked up by its Funder Registry DOI."
},
"remainingUsd": {
"type": "number",
"description": "USD left in today's OpenAlex budget after this call."
},
"resetsInSeconds": {
"type": "number",
"description": "Seconds until the daily budget refills (midnight UTC)."
},
"prepaidRemainingUsd": {
"type": "number",
"description": "USD left in the prepaid balance — a separate pool OpenAlex draws on only after the daily allowance runs out, and which the daily reset does not refill. Add it to `remainingUsd` for the full spendable amount. Absent when the account holds no prepaid balance."
}
},
"description": "What this call cost against the OpenAlex daily budget and what is left of it. Price a full traversal before committing to it: `totalCount` ÷ `per_page` × `costUsd` against `remainingUsd`. Absent when OpenAlex omitted the accounting headers.",
"additionalProperties": false
},
"notice": {
"type": "string",
"description": "Guidance notice. Set when per_page, sample, or slice.limit was clamped (requested and applied sizes), a native filter string was converted, a first call has no matches, a continuation is exhausted, an id lookup ignores search criteria, semantic search reports a candidate total and whether a later `page` holds more, a sample draws more than one page (the next `page`, its last page, or that `seed` makes it pageable), an author array is possibly capped (100 list entries or 5,000 stored singleton entries), a slice offset is past the end, or slice replaces select. Absent otherwise."
},
"results": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"display_name"
],
"properties": {
"id": {
"type": "string",
"description": "OpenAlex ID URL (e.g., \"https://openalex.org/W2741809807\"). `windows` names records by the bare ID (\"W2741809807\"); a keyword keeps its URL."
},
"display_name": {
"type": [
"string",
"null"
],
"description": "Entity name or work title. null when OpenAlex holds no title for the record (paratext works and other untitled entries) — use `id` to identify it."
}
},
"description": "A single OpenAlex entity record. `id` is always present and `display_name` is always returned (though it may be null); additional fields vary by entity_type and `select`.",
"additionalProperties": {}
},
"description": "OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Additional fields depend on entity_type and select. Possibly capped author arrays and `slice` windows are in `windows`."
},
"windows": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"field",
"offset",
"shown",
"total",
"possibly_capped",
"next"
],
"properties": {
"id": {
"type": "string",
"description": "Bare ID of the record."
},
"next": {
"anyOf": [
{
"type": "object",
"required": [
"tool",
"arguments"
],
"properties": {
"tool": {
"type": "string",
"const": "openalex_search_entities",
"description": "Tool to call."
},
"arguments": {
"type": "object",
"properties": {},
"description": "Arguments to pass as-is.",
"additionalProperties": {}
}
},
"additionalProperties": false
},
{
"type": "null"
}
],
"description": "`id` + `slice` call for the next elements; null at the array end."
},
"field": {
"type": "string",
"description": "Array field."
},
"shown": {
"type": "number",
"description": "Elements shown."
},
"total": {
"type": "number",
"description": "Stored array length returned to this call, not a complete byline total."
},
"offset": {
"type": "number",
"description": "Index of the first element shown."
},
"possibly_capped": {
"type": "boolean",
"description": "True for exactly 100 authorships or author_list entries on a list record, or 5,000 on a singleton lookup: the byline may be longer than the returned array."
}
},
"description": "An array returned in part, or possibly in part.",
"additionalProperties": false
},
"description": "Author arrays possibly capped at 100 on a list record or 5,000 stored on a singleton, and the window of every `slice` call — present even when the slice holds the whole array. A slice window's `next` repeats its `slice.limit` from the following offset; `next` is null at the stored-array end, including a possibly capped singleton."
},
"totalCount": {
"type": "number",
"description": "Total results matching the query/filters across all pages."
}
},
"additionalProperties": false
}{
"readOnlyHint": true,
"openWorldHint": true,
"idempotentHint": true
}openalex_analyze_trends — Openalex Analyze Trends
Aggregate OpenAlex entities into groups and count them. Use for trend analysis (group works by publication_year), distribution analysis (group by oa_status, type, country), and comparative analysis (group by institution or topic). Combine with filters to scope the analysis. Returns up to 200 groups per page — use order "key" and cursor pagination for fields with many distinct values. 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.
{
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"entity_type",
"group_by"
],
"properties": {
"order": {
"enum": [
"count",
"key"
],
"type": "string",
"description": "Sort order for groups. Without a cursor, omit or pass \"count\" (default) to return the top-N groups by count descending — no further pages. Pass \"key\" to enumerate distinct values in key-ascending order with cursor pagination. Boolean fields keep count order even with \"key\" and do not support cursor pagination. A supplied cursor selects key order even if 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."
},
"cursor": {
"type": "string",
"minLength": 1,
"description": "Pagination cursor from a previous response. A supplied cursor selects key order even if order is omitted or \"count\". Pass the next_cursor from the previous response to advance, subject to the stop conditions below. Omit it on the first call — an empty string is rejected, since a supplied-but-blank cursor is a caller mistake rather than a request for the first page. 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."
},
"filters": {
"type": "object",
"description": "Filter record object, using the same value syntax as openalex_search_entities; native string input is unavailable here. Narrows the population before aggregation. Country fields take ISO 3166-1 alpha-2 codes (\"DE\") and `language` takes ISO 639-1 codes (\"en\"); alpha-3, numeric, ISO 639-2, and English-name forms are converted — a multi-word name is one value, so \"South Korea|Japan\" ORs two countries — and a value that cannot be a country code is rejected. A multi-word value on a field matched as whole text (display_name, awards.funder_display_name, geo.city, raw_affiliation_strings) matches as one phrase (\"National Science Foundation\"; a comma, as in \"University of California, Berkeley\", is part of it, and it cannot contain a double quote of its own), \"!\" and \"|\" included, but cannot be ANDed with \"+\" in the same field. A value sent unquoted must have paired double quotes; balanced search phrases remain valid. A value mixing \"|\" with an AND operator — a \"+\", or a space on a field where words mean AND — is rejected, since OpenAlex drops every \"|\" alternative that ANDs more than one value; put the AND in a second key instead. For full-text within filters, use abstract.search, title.search, or default.search — there is no bare 'search' filter key. Example: group works by year filtered to a specific topic.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"group_by": {
"type": "string",
"minLength": 1,
"description": "Field to group by. Works examples: \"publication_year\", \"type\", \"oa_status\", \"primary_topic.field.id\", \"authorships.institutions.country_code\", \"is_retracted\". The native \":include_unknown\" suffix also enables include_unknown, even when the boolean is false; passing both adds the suffix once. OpenAlex builds authorships.countries groups from only the first 100 authorships of each work; authorships.institutions.country_code counts every authorship. Authors: \"last_known_institutions.country_code\", \"has_orcid\". Sources: \"type\", \"is_oa\", \"country_code\". Awards: \"start_year\", \"funder.id\", \"funding_type\" — groups count awards and do not sum amount_usd. Not all fields support group_by — call openalex_describe_fields(entity_type, \"group_by\") for the groupable set."
},
"per_page": {
"type": "integer",
"default": 200,
"minimum": 1,
"description": "Maximum groups per page. Default 200 (the upstream cap); positive integers above 200 clamp to 200 with a notice unless OPENALEX_CLAMP_PER_PAGE=false restores rejection. A real top-N knob in count order without a cursor (the default) — reduce to return only the highest-count groups."
},
"entity_type": {
"enum": [
"works",
"authors",
"sources",
"institutions",
"topics",
"keywords",
"publishers",
"funders",
"awards"
],
"type": "string",
"description": "Entity type to aggregate."
},
"include_unknown": {
"type": "boolean",
"default": false,
"description": "Add a group for entities with no value for the grouped field. Hidden by default unless group_by has the native \":include_unknown\" suffix, which enables this setting even when the boolean is false; passing both adds the suffix once. That group carries `is_unknown: true`; OpenAlex keys it -111 or -111.0 on numeric fields, \"unknown\" on text fields and under order \"key\", and an ID ending in /unknown on ID fields — a sentinel, not a measured value. The key is not a filter value: passing -111 as a filter matches a numeric range, not the entities with no value. Boolean fields have no separate group — a missing value counts as false."
}
},
"additionalProperties": false
}{
"type": "object",
"anyOf": [
{
"not": {
"required": [
"error"
]
},
"required": [
"meta",
"groups",
"echo",
"totalCount"
]
},
{
"required": [
"error"
]
}
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"echo": {
"type": "string",
"description": "Compact echo of the criteria (entity_type, group_by, filters) and effective include_unknown and order settings — surfaces what was actually requested when no groups are returned."
},
"meta": {
"type": "object",
"required": [
"count",
"groups_count",
"next_cursor"
],
"properties": {
"count": {
"type": "number",
"description": "Total entities matching the filters (before grouping)."
},
"next_cursor": {
"type": [
"string",
"null"
],
"description": "Upstream continuation cursor; null when OpenAlex supplies none. A non-null cursor does not guarantee unseen groups. 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."
},
"groups_count": {
"type": [
"number",
"null"
],
"description": "Number of groups on this page (max 200)."
}
},
"description": "Aggregation metadata.",
"additionalProperties": false
},
"error": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "JSON-RPC error code for this failure."
},
"data": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"examples": [
"unmatched_quote_in_filter_value",
"rate_limited",
"upstream_budget_exhausted",
"upstream_plan_required",
"upstream_timeout",
"upstream_unavailable",
"upstream_unauthorized",
"upstream_forbidden",
"comma_in_filter_value",
"invalid_country_code",
"mixed_and_or_in_filter_value",
"quote_in_filter_value",
"upstream_invalid_params",
"upstream_invalid_id_value",
"upstream_ungroupable_group_by",
"upstream_invalid_params_other",
"upstream_validation_failed",
"upstream_missing_group_by"
],
"description": "Machine-readable failure mode. Declared by this tool: `unmatched_quote_in_filter_value`: A filter value sent unquoted has an unmatched double quote. `rate_limited`: OpenAlex throttled the request for exceeding its per-second ceiling (HTTP 429). `upstream_budget_exhausted`: The OpenAlex daily usage budget is spent (HTTP 429). `upstream_plan_required`: A filter is one OpenAlex serves only on a paid plan — created_date, updated_date, or a from_/to_ form of either (HTTP 429 \"Plan upgrade required\"). `upstream_timeout`: OpenAlex did not respond within the request deadline. `upstream_unavailable`: OpenAlex was unreachable or unusable — HTTP 503, a connection failure, or a body that was empty, HTML, or unparseable JSON. `upstream_unauthorized`: OpenAlex rejected the API key (HTTP 401). `upstream_forbidden`: OpenAlex denied access to the requested resource (HTTP 403). `comma_in_filter_value`: A filter value contains a comma, which collides with the OpenAlex filter separator, on a field that takes no phrase. Free-text search filters (any `.search` key, such as title.search or abstract.search.exact) and fields matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, geo.city, geo.region, raw_affiliation_strings, block_key, parsed_longest_name, biblio fields) are exempt: a comma there is sent quoted, as part of the phrase. `invalid_country_code`: A country filter (a key ending in country_code, country_codes, or countries) holds a segment that cannot be a country code, such as a misspelled name, an unassigned number, \"unknown\", or two codes joined by a space (\"DE US\"). `mixed_and_or_in_filter_value`: A filter value puts \"|\" (OR) beside an AND operator — a \"+\", or a space on a field where words mean AND (on a country field a space is always part of a country name, and inside a language name it is part of the name). OpenAlex splits the value on \"|\" first and drops every alternative that ANDs more than one value, so no single value can carry both. `quote_in_filter_value`: A filter value sent to OpenAlex double-quoted holds a double quote inside its outer pair — a value the server quotes (a multi-word value on a field matched as whole text, or a value holding a comma on such a field or on a free-text search filter), or one the caller wrapped in double quotes on any filter but a free-text search. `upstream_invalid_params`: OpenAlex rejected an invalid group_by or filter field name (HTTP 400). `upstream_invalid_id_value`: An entity-ID filter received a value that is not an OpenAlex ID — usually a name (HTTP 400). `upstream_ungroupable_group_by`: group_by targets a field OpenAlex cannot aggregate — a raw date, a decimal score, a *.search operator, a field such as display_name, doi, or referenced_works, or a concept key on authors, which OpenAlex reports as an invalid OpenAlex ID (HTTP 400). `upstream_invalid_params_other`: OpenAlex rejected the request (HTTP 400) for a reason other than an invalid field name. `upstream_validation_failed`: OpenAlex rejected the request as semantically invalid (HTTP 422). `upstream_missing_group_by`: OpenAlex answered with its plain list shape, carrying no aggregation for the requested group_by. Other values are possible when a failure originates below the handler."
},
"recovery": {
"type": "object",
"required": [
"hint"
],
"properties": {
"hint": {
"type": "string"
}
},
"description": "Actionable next step for the caller.",
"additionalProperties": {}
},
"retryable": {
"type": "boolean",
"description": "Whether retrying may succeed."
}
},
"additionalProperties": {}
},
"message": {
"type": "string",
"description": "Human-readable description of what went wrong."
}
},
"description": "Present when the call failed. Absent on success.",
"additionalProperties": {}
},
"budget": {
"type": "object",
"required": [
"costUsd",
"remainingUsd",
"resetsInSeconds"
],
"properties": {
"costUsd": {
"type": "number",
"description": "USD this call spent. Aggregation is priced far below paging the same entities, so a group_by is the cheap way to size a population before searching it."
},
"remainingUsd": {
"type": "number",
"description": "USD left in today's OpenAlex budget after this call."
},
"resetsInSeconds": {
"type": "number",
"description": "Seconds until the daily budget refills (midnight UTC)."
},
"prepaidRemainingUsd": {
"type": "number",
"description": "USD left in the prepaid balance — a separate pool OpenAlex draws on only after the daily allowance runs out, and which the daily reset does not refill. Add it to `remainingUsd` for the full spendable amount. Absent when the account holds no prepaid balance."
}
},
"description": "What this call cost against the OpenAlex daily budget and what is left of it — weigh `remainingUsd` against `costUsd` before enumerating every group with `order: \"key\"`. Absent when OpenAlex omitted the accounting headers.",
"additionalProperties": false
},
"groups": {
"type": "array",
"items": {
"type": "object",
"required": [
"key",
"key_display_name",
"count"
],
"properties": {
"key": {
"type": "string",
"description": "Group key (OpenAlex ID or raw value), exactly as OpenAlex returns it."
},
"count": {
"type": "number",
"description": "Number of entities in this group."
},
"is_unknown": {
"type": "boolean",
"const": true,
"description": "Present only on the group include_unknown adds for entities with no value; its key is an OpenAlex sentinel (-111, -111.0, unknown, or an ID ending in /unknown), not a measured value."
},
"key_display_name": {
"type": [
"string",
"null"
],
"description": "Human-readable group label as plain text, with HTML entities decoded and markup removed; null when OpenAlex supplies no label. Text output uses the group key when the label is null."
}
},
"description": "A single aggregation group with its key, display label, and entity count.",
"additionalProperties": false
},
"description": "Aggregation groups with counts."
},
"notice": {
"type": "string",
"description": "Guidance notice. Set when per_page was clamped (requested and applied sizes), on every effective key-order call (upstream wrap warning and stopping conditions), when a first call returns no groups (recovery suggestions), when a cursor continuation returns none because the traversal is already finished, when a count-order page is full and more groups likely exist (truncation signal with narrowing advice), or when works are grouped by authorships.countries, which OpenAlex counts from only the first 100 authorships of each work. Absent otherwise."
},
"totalCount": {
"type": "number",
"description": "Total entities matching the filters before grouping (across all pages)."
}
},
"additionalProperties": false
}{
"readOnlyHint": true,
"openWorldHint": true,
"idempotentHint": true
}openalex_get_citation_graph — Openalex Get Citation Graph
Walk the citation graph one hop from a seed work. Direction picks the edge: incoming citations (`cites`), the seed's own references (`cited_by`), or OpenAlex's algorithmically-related works (`related_to`). Note: `direction` follows OpenAlex's filter convention, which inverts the common English reading — `cites` returns works that cite the seed; `cited_by` returns works the seed cites. Results use the works schema; combine with filters/sort to narrow further. A page holds `per_page` works and `next_cursor` continues it.
{
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"seed_id",
"direction"
],
"properties": {
"sort": {
"type": "string",
"description": "Sort field. Prefix with \"-\" for descending. Comma-separate for a multi-key sort, applied left to right, with the \"-\" prefix set per key (\"-publication_year,cited_by_count\" sorts by year descending, then citations ascending). Common: \"cited_by_count\", \"-publication_date\". Default is OpenAlex relevance."
},
"cursor": {
"type": "string",
"minLength": 1,
"description": "Pagination cursor from a previous response. Pass to get the next page. Omit it on the first call — an empty string is rejected, since a supplied-but-blank cursor is a caller mistake rather than a request for the first page."
},
"select": {
"type": "array",
"items": {
"type": "string"
},
"description": "OpenAlex work field names to return. Always returned: id, display_name. Defaults to the curated works select if omitted. `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `[\"*\"]` includes it. A wide projection on a large page makes a large response, so name the fields you need."
},
"filters": {
"type": "object",
"description": "Additional filter record object to narrow the graph, using the same value syntax as openalex_search_entities; native string input is unavailable here. Example: publication_year=\">2020\", is_oa=\"true\". Country fields take ISO 3166-1 alpha-2 codes (\"DE\") and `language` takes ISO 639-1 codes (\"en\"); alpha-3, numeric, ISO 639-2, and English-name forms are converted — a multi-word name is one value, so \"South Korea|Japan\" ORs two countries — and a value that cannot be a country code is rejected. A multi-word value on a field matched as whole text (display_name, awards.funder_display_name, authorships.institutions.geo.city, raw_affiliation_strings) matches as one phrase (\"National Science Foundation\"; a comma, as in \"University of California, Berkeley\", is part of it, and it cannot contain a double quote of its own), \"!\" and \"|\" included, but cannot be ANDed with \"+\" in the same field. A value sent unquoted must have paired double quotes; balanced search phrases remain valid. Filters are checked before the seed lookup. A value mixing \"|\" with an AND operator — a \"+\", or a space on a field where words mean AND — is rejected, since OpenAlex drops every \"|\" alternative that ANDs more than one value; put the AND in a second key instead. Do not include cites/cited_by/related_to, nor an alias of one such as cited_works — those keys are set by the `direction` parameter.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"seed_id": {
"type": "string",
"minLength": 1,
"description": "Seed work identifier. Accepts OpenAlex work ID (\"W2741809807\", or its OpenAlex record-page or API URL such as \"https://openalex.org/works/W2741809807\"), DOI (\"10.1038/nature12373\" or full URL), or PMID (\"12345678\" or \"https://pubmed.ncbi.nlm.nih.gov/12345678\"). A PMCID is recognized too, bare or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work's PMID or DOI instead. Use openalex_resolve_name first if you only have a title."
},
"per_page": {
"type": "integer",
"default": 25,
"minimum": 1,
"description": "Results per page. Default 25; a larger value returns at most 100 and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false)."
},
"direction": {
"enum": [
"cites",
"cited_by",
"related_to"
],
"type": "string",
"description": "\"cites\": works that cite seed_id (incoming citations). \"cited_by\": works that seed_id cites (its reference list). \"related_to\": OpenAlex algorithmically-related works (~8-30 typical, may be empty for less-cited seeds)."
}
},
"additionalProperties": false
}{
"type": "object",
"anyOf": [
{
"not": {
"required": [
"error"
]
},
"required": [
"meta",
"results",
"echo",
"totalCount"
]
},
{
"required": [
"error"
]
}
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"echo": {
"type": "string",
"description": "Compact echo of seed_id, direction, filters, sort — surfaces what was actually queried when no edges are returned."
},
"meta": {
"type": "object",
"required": [
"count",
"per_page",
"next_cursor"
],
"properties": {
"count": {
"type": "number",
"description": "Total edges from seed_id in this direction (across all pages)."
},
"per_page": {
"type": "number",
"description": "Page size OpenAlex echoed for this request — the applied per_page after clamping, not the number of records returned. A short or exhausted page carries fewer records than this."
},
"next_cursor": {
"type": [
"string",
"null"
],
"description": "Cursor for the next page; pass it back unchanged as `cursor`. null when no further page exists, except on a final page that exactly fills `per_page`: OpenAlex still returns a cursor there, and it leads to an empty page."
}
},
"description": "Result metadata including pagination.",
"additionalProperties": false
},
"error": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "JSON-RPC error code for this failure."
},
"data": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"examples": [
"unmatched_quote_in_filter_value",
"rate_limited",
"upstream_budget_exhausted",
"upstream_plan_required",
"upstream_timeout",
"upstream_unavailable",
"upstream_unauthorized",
"upstream_forbidden",
"comma_in_filter_value",
"invalid_country_code",
"mixed_and_or_in_filter_value",
"quote_in_filter_value",
"upstream_invalid_params",
"upstream_invalid_id_value",
"upstream_sort_requires_search",
"upstream_invalid_params_other",
"reserved_filter_key",
"id_type_mismatch",
"id_dot_segment",
"entity_not_found",
"response_too_large"
],
"description": "Machine-readable failure mode. Declared by this tool: `unmatched_quote_in_filter_value`: A filter value sent unquoted has an unmatched double quote. `rate_limited`: OpenAlex throttled the request for exceeding its per-second ceiling (HTTP 429). `upstream_budget_exhausted`: The OpenAlex daily usage budget is spent (HTTP 429). `upstream_plan_required`: A filter is one OpenAlex serves only on a paid plan — created_date, updated_date, or a from_/to_ form of either (HTTP 429 \"Plan upgrade required\"). `upstream_timeout`: OpenAlex did not respond within the request deadline. `upstream_unavailable`: OpenAlex was unreachable or unusable — HTTP 503, a connection failure, or a body that was empty, HTML, or unparseable JSON. `upstream_unauthorized`: OpenAlex rejected the API key (HTTP 401). `upstream_forbidden`: OpenAlex denied access to the requested resource (HTTP 403). `comma_in_filter_value`: A filter value contains a comma, which collides with the OpenAlex filter separator, on a field that takes no phrase. Free-text search filters (any `.search` key, such as title.search or abstract.search.exact) and fields matched as whole text (display_name, awards.funder_display_name, awards.funder_award_id, authorships.institutions.geo.city, authorships.institutions.geo.region, raw_affiliation_strings, biblio fields) are exempt: a comma there is sent quoted, as part of the phrase. `invalid_country_code`: A country filter (a key ending in country_code, country_codes, or countries) holds a segment that cannot be a country code, such as a misspelled name, an unassigned number, \"unknown\", or two codes joined by a space (\"DE US\"). `mixed_and_or_in_filter_value`: A filter value puts \"|\" (OR) beside an AND operator — a \"+\", or a space on a field where words mean AND (on a country field a space is always part of a country name, and inside a language name it is part of the name). OpenAlex splits the value on \"|\" first and drops every alternative that ANDs more than one value, so no single value can carry both. `quote_in_filter_value`: A filter value sent to OpenAlex double-quoted holds a double quote inside its outer pair — a value the server quotes (a multi-word value on a field matched as whole text, or a value holding a comma on such a field or on a free-text search filter), or one the caller wrapped in double quotes on any filter but a free-text search. `upstream_invalid_params`: OpenAlex rejected an invalid filter, select, or sort field name (HTTP 400). `upstream_invalid_id_value`: An entity-ID filter received a value that is not an OpenAlex ID — usually a name (HTTP 400). `upstream_sort_requires_search`: sort=-relevance_score was used but the citation-graph query has no active search (HTTP 400). `upstream_invalid_params_other`: OpenAlex rejected the request (HTTP 400) for a reason other than an invalid field name. `reserved_filter_key`: filters contains cites/cited_by/related_to, or an alias of one such as cited_works — the direction parameter reserves those keys. `id_type_mismatch`: seed_id is a native OpenAlex ID of another entity type — an author, source, institution, topic, publisher, funder, award, or concept ID rather than a work ID. `id_dot_segment`: seed_id holds a `.` or `..` path segment, written plainly or percent-encoded. `entity_not_found`: OpenAlex has no work matching the seed_id. `response_too_large`: The response's structuredContent or content[] text would exceed 8,000,000 bytes — a wide `select` on a large page. A page is never shortened to fit. Other values are possible when a failure originates below the handler."
},
"recovery": {
"type": "object",
"required": [
"hint"
],
"properties": {
"hint": {
"type": "string"
}
},
"description": "Actionable next step for the caller.",
"additionalProperties": {}
},
"retryable": {
"type": "boolean",
"description": "Whether retrying may succeed."
}
},
"additionalProperties": {}
},
"message": {
"type": "string",
"description": "Human-readable description of what went wrong."
}
},
"description": "Present when the call failed. Absent on success.",
"additionalProperties": {}
},
"budget": {
"type": "object",
"required": [
"costUsd",
"remainingUsd",
"resetsInSeconds"
],
"properties": {
"costUsd": {
"type": "number",
"description": "USD this call spent, covering both upstream requests — the seed validation lookup and the graph page itself. The seed lookup is unbilled unless seed_id carries characters past the identifier itself (`W2741809807#x` still resolves to W2741809807), which OpenAlex bills like one list request."
},
"remainingUsd": {
"type": "number",
"description": "USD left in today's OpenAlex budget after this call."
},
"resetsInSeconds": {
"type": "number",
"description": "Seconds until the daily budget refills (midnight UTC)."
},
"prepaidRemainingUsd": {
"type": "number",
"description": "USD left in the prepaid balance — a separate pool OpenAlex draws on only after the daily allowance runs out, and which the daily reset does not refill. Add it to `remainingUsd` for the full spendable amount. Absent when the account holds no prepaid balance."
}
},
"description": "What this call cost against the OpenAlex daily budget and what is left of it. Price a full walk before committing to it: `totalCount` ÷ `per_page` × `costUsd` against `remainingUsd`. Absent when OpenAlex omitted the accounting headers.",
"additionalProperties": false
},
"notice": {
"type": "string",
"description": "Guidance notice. Set when per_page was clamped (requested and applied sizes), when no edges are returned — a first call suggests verifying the seed_id, broadening filters, or trying a different direction; a cursor continuation says the walk is already past its last edge — and when a work carries exactly 100 authorships or author_list entries (possibly capped). Absent otherwise."
},
"results": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"display_name"
],
"properties": {
"id": {
"type": "string",
"description": "OpenAlex work ID."
},
"display_name": {
"type": [
"string",
"null"
],
"description": "Work title. null when OpenAlex holds no title for the record (paratext works and other untitled entries) — use `id` to identify it."
}
},
"description": "A single OpenAlex work record on the citation graph. Additional fields vary by `select`.",
"additionalProperties": {}
},
"description": "Works on the citation graph in this direction. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Possibly capped author arrays are in `windows`."
},
"windows": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"field",
"offset",
"shown",
"total",
"possibly_capped",
"next"
],
"properties": {
"id": {
"type": "string",
"description": "Bare ID of the record."
},
"next": {
"anyOf": [
{
"type": "object",
"required": [
"tool",
"arguments"
],
"properties": {
"tool": {
"type": "string",
"const": "openalex_search_entities",
"description": "Tool to call."
},
"arguments": {
"type": "object",
"properties": {},
"description": "Arguments to pass as-is.",
"additionalProperties": {}
}
},
"additionalProperties": false
},
{
"type": "null"
}
],
"description": "`id` + `slice` call for the next elements; null at the array end."
},
"field": {
"type": "string",
"description": "Array field."
},
"shown": {
"type": "number",
"description": "Elements shown."
},
"total": {
"type": "number",
"description": "Stored array length returned to this call, not a complete byline total."
},
"offset": {
"type": "number",
"description": "Index of the first element shown."
},
"possibly_capped": {
"type": "boolean",
"description": "True for exactly 100 authorships or author_list entries on a list record, or 5,000 on a singleton lookup: the byline may be longer than the returned array."
}
},
"description": "An array returned in part, or possibly in part.",
"additionalProperties": false
},
"description": "Author arrays possibly capped at 100 on a list record or 5,000 stored on a singleton, and the window of every `slice` call — present even when the slice holds the whole array. A slice window's `next` repeats its `slice.limit` from the following offset; `next` is null at the stored-array end, including a possibly capped singleton."
},
"totalCount": {
"type": "number",
"description": "Total edges from seed_id in this direction across all pages."
}
},
"additionalProperties": false
}{
"readOnlyHint": true,
"openWorldHint": true,
"idempotentHint": true
}openalex_describe_fields — Openalex Describe Fields
List valid field names for an OpenAlex entity type and context (filter, group_by, or select). Use proactively before constructing a filter or group_by to avoid invalid-field 400 errors. Pass `query` to rank the list by name similarity — useful when you have a partial or guessed field name. Ranking never drops a field: the full list comes back either way.
{
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"entity_type",
"context"
],
"properties": {
"query": {
"type": "string",
"description": "Optional partial or guessed field name to sort results by similarity. Pass the field you tried (e.g. \"funder\") to get the closest matches first. The complete field list is returned either way — a query reorders it, it does not filter it, so a nested value's parent object (e.g. `summary_stats` for \"h_index\") is still reachable further down."
},
"context": {
"enum": [
"filter",
"group_by",
"select"
],
"type": "string",
"description": "Field usage context. \"filter\": fields accepted in the filter param; created_date, updated_date, and their from_/to_ forms, where listed, are ones OpenAlex serves only on a paid plan, and a filter on one fails without it. \"group_by\": fields accepted in group_by — a subset of the filter set that leaves out what OpenAlex refuses to aggregate (raw dates, *.search operators, decimal scores, display_name, external-ID fields, and fields OpenAlex looks up from a related entity, such as primary_location.source.country_code, among them). \"select\": fields accepted in select."
},
"entity_type": {
"enum": [
"works",
"authors",
"sources",
"institutions",
"topics",
"keywords",
"publishers",
"funders",
"awards"
],
"type": "string",
"description": "OpenAlex entity type to list fields for."
}
},
"additionalProperties": false
}{
"type": "object",
"anyOf": [
{
"not": {
"required": [
"error"
]
},
"required": [
"entity_type",
"context",
"fields",
"total"
]
},
{
"required": [
"error"
]
}
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"error": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "JSON-RPC error code for this failure."
},
"data": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Machine-readable failure mode."
},
"recovery": {
"type": "object",
"required": [
"hint"
],
"properties": {
"hint": {
"type": "string"
}
},
"description": "Actionable next step for the caller.",
"additionalProperties": {}
},
"retryable": {
"type": "boolean",
"description": "Whether retrying may succeed."
}
},
"additionalProperties": {}
},
"message": {
"type": "string",
"description": "Human-readable description of what went wrong."
}
},
"description": "Present when the call failed. Absent on success.",
"additionalProperties": {}
},
"total": {
"type": "number",
"description": "Total number of valid fields for this entity_type + context."
},
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "Every valid field name for this entity_type + context — the complete pool, ranked by similarity when `query` is provided. Never truncated, so this always holds `total` entries."
},
"context": {
"type": "string",
"description": "Context queried (filter, group_by, or select)."
},
"entity_type": {
"type": "string",
"description": "Entity type queried."
}
},
"additionalProperties": false
}{
"readOnlyHint": true,
"idempotentHint": true
}Prompts
2 advertised
openalex_literature_review
Guides a systematic literature search: formulate query, search, filter, analyze citation network, synthesize findings.
- topic
- Research topic or question to review (e.g., "CRISPR off-target effects in human cell lines"). (required)
- scope
- "narrow": focused on specific question. "broad": survey of the field. Defaults to "narrow" if omitted.
openalex_research_landscape
Analyzes the research landscape for a topic: volume trends, top authors/institutions, open access rates, funding sources.
- topic
- Research area to analyze (e.g., "single-cell RNA sequencing"). (required)
Resources
1 advertised
openalex-guide
Gates and identifier chains for the openalex_* tools — which filter key answers the question (topic or text, institution or its lineage, venue, funders or awards), how to read group_by counts, where W/A/S/I/T/P/F/G IDs come from, and how to pin an author without picking a namesake. Use when answering literature, citation, bibliometric, author, institution, or research-funding questions with openalex_* tools.
skill://openalex-guide/SKILL.mdio.modelcontextprotocol/skills
skill://{+path}90-day uptime
A lightweight connection check about every six hours, sampled on hosts with many listings. It shows the server answered, not that its tools work.
- 2026-11-07: 1 of 1 checks answered
- 2026-11-09: 1 of 1 checks answered
- 2026-11-10: 1 of 1 checks answered
30-day availability
Aggregated from read-only remote checks; this is not a tool-execution or security guarantee.
- Successful checks
- 1 of 1 (100%)
- Average latency
- 627 ms
- 95th percentile latency
- 627 ms
Maintenance history
Public check and source-refresh events. Editorial and ownership records remain private.
- · probe · passed · 627 ms · metadata changed · 5 tool(s), 1 resource(s), 1 resource template(s), 2 prompt(s)
Connect from your client
Each snippet registers the same endpoint; pick the client you use.
Claude Code
claude mcp add --transport http openalex-mcp-server https://openalex.caseyjhand.com/mcpCodex
codex mcp add openalex-mcp-server --url https://openalex.caseyjhand.com/mcpCursor
{
"mcpServers": {
"openalex-mcp-server": {
"url": "https://openalex.caseyjhand.com/mcp"
}
}
}Cline
{
"mcpServers": {
"openalex-mcp-server": {
"type": "streamableHttp",
"url": "https://openalex.caseyjhand.com/mcp",
"disabled": false
}
}
}VS Code
{
"servers": {
"openalex-mcp-server": {
"type": "http",
"url": "https://openalex.caseyjhand.com/mcp"
}
}
}Gemini CLI
gemini mcp add --transport http openalex-mcp-server https://openalex.caseyjhand.com/mcpClaude Desktop
https://openalex.caseyjhand.com/mcpWindsurf
{
"mcpServers": {
"openalex-mcp-server": {
"serverUrl": "https://openalex.caseyjhand.com/mcp"
}
}
}Let your agent find its tools
Find servers like openalex-mcp-server from your agent. Add Koskamo once to search the index for endpoints, access requirements, and client setup. Public discovery needs no key.