@cyanheads/openalex-mcp-server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +8 -3
  2. package/CLAUDE.md +8 -3
  3. package/Dockerfile +3 -3
  4. package/README.md +10 -4
  5. package/changelog/0.10.x/0.10.0.md +30 -0
  6. package/dist/config/server-config.d.ts +2 -0
  7. package/dist/config/server-config.d.ts.map +1 -1
  8. package/dist/config/server-config.js +10 -0
  9. package/dist/config/server-config.js.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +8 -2
  13. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +53 -38
  15. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +7 -1
  17. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +17 -12
  19. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +6 -0
  21. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +8 -1
  23. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +23 -2
  25. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  26. package/dist/mcp-server/tools/definitions/search-entities.tool.js +70 -15
  27. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  28. package/dist/mcp-server/tools/page-size.d.ts +18 -0
  29. package/dist/mcp-server/tools/page-size.d.ts.map +1 -0
  30. package/dist/mcp-server/tools/page-size.js +28 -0
  31. package/dist/mcp-server/tools/page-size.js.map +1 -0
  32. package/dist/mcp-server/tools/response-budget.d.ts +3 -4
  33. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/response-budget.js +46 -26
  35. package/dist/mcp-server/tools/response-budget.js.map +1 -1
  36. package/dist/services/openalex/filter-values.d.ts +2 -0
  37. package/dist/services/openalex/filter-values.d.ts.map +1 -1
  38. package/dist/services/openalex/filter-values.js +56 -0
  39. package/dist/services/openalex/filter-values.js.map +1 -1
  40. package/dist/services/openalex/openalex-service.d.ts +6 -2
  41. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  42. package/dist/services/openalex/openalex-service.js +15 -9
  43. package/dist/services/openalex/openalex-service.js.map +1 -1
  44. package/dist/services/openalex/types.d.ts +5 -2
  45. package/dist/services/openalex/types.d.ts.map +1 -1
  46. package/package.json +5 -5
  47. package/server.json +31 -3
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openalex-mcp-server
4
- **Version:** 0.9.0
4
+ **Version:** 0.10.0
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
@@ -42,6 +42,8 @@ No resources — entity lookups need `select` for payload control, which fits to
42
42
  | `OPENALEX_API_KEY` | No | OpenAlex account API key, sent as `api_key=` (free from https://openalex.org/settings/api); omit for anonymous access |
43
43
  | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key |
44
44
  | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` |
45
+ | `OPENALEX_FILTER_STRING_INPUT` | No | Default: `true`; accept unambiguous native filter strings on `openalex_search_entities`; `false` restores record-only input. Strict boolean parsing rejects invalid values. |
46
+ | `OPENALEX_CLAMP_PER_PAGE` | No | Default: `true`; clamp positive integer `per_page` to 100 for search/citation and 200 for analysis, with a notice, log, and counter. `false` restores rejecting advertised maxima. Strict boolean parsing; semantic cap 50 and sample maxima still reject. |
45
47
 
46
48
  **Session mode:** `createApp({ sessionMode: 'stateless' })` in `src/index.ts` — no tool gates on `ctx.requestInput`, so every call completes in one round trip. An explicit `MCP_SESSION_MODE` still overrides it; `.env.example` and the `Dockerfile` set the same value. No service holds a watcher, socket, or ref'd timer, so there is no `teardown` hook.
47
49
 
@@ -189,6 +191,8 @@ async handler(input, ctx) {
189
191
 
190
192
  **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture. Don't extract a shared `errors[]` constant; per-tool repetition is the intended cost of locality.
191
193
 
194
+ Native-filter parser failures raised during schema preprocessing carry explicit recovery guidance: the handler boundary has not run yet, so handler-contract hint filling cannot supply it.
195
+
192
196
  The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and nothing else of the contract; the framework fills the calling tool's `recovery` for that reason at the handler boundary, and the errors bubble through and are auto-classified. Three service throws set their own `recovery: { hint }`, which wins over the fill: the PMCID 404, since the generic "verify the ID" recovery would send the caller back to retry an identifier OpenAlex never indexes; `upstream_budget_exhausted` on a server with no `OPENALEX_API_KEY`, whose hint adds the free-key advice to the contract's key-neutral midnight-UTC recovery (a keyed server's budget is already the larger one, so it gets the contract text alone); and `upstream_unavailable` on a semantic search — including the HTTP 400 `Failed to embed query:` and `Semantic search is not configured` bodies, relabelled from caller errors to that outage — whose hint adds the keyword-search fallback the generic outage recovery cannot name. One status is read past its code: an HTTP 429 whose body `error` is `Plan upgrade required` — a filter OpenAlex serves only on a paid plan — is thrown as `Forbidden` with `upstream_plan_required` and `retryable: false` under a message of the server's own, so it fails on its first request — no wait clears a plan gate. Every service-thrown entry carries `thrownBy: 'service'` — lint-only metadata that tells `error-contract-unthrown`, whose scan reads the handler body alone, to skip a reason produced a layer below it. Handler-local precondition throws stay inline and unmarked so the rule keeps checking them: `semantic_without_query`, `semantic_per_page_cap`, `semantic_with_cursor`, `page_without_semantic`, `sample_with_cursor`, `sample_with_page`, `sample_with_semantic`, `sample_with_sort`, `seed_without_sample`, `slice_without_id`, and `slice_field_not_array` on `openalex_search_entities`; `reserved_filter_key` and the seed-lookup `entity_not_found` on `openalex_get_citation_graph`; `filters_without_entity_type` on `openalex_resolve_name`. Recovery assertions belong on the contract path (`runToolContract`), where the fill applies — a direct `definition.handler(...)` or service throw carries only what the throw site wrote.
193
197
 
194
198
  **Fallback for ad-hoc throws:** error factories or plain `Error`.
@@ -210,12 +214,12 @@ See framework CLAUDE.md for the full auto-classification table and all available
210
214
  src/
211
215
  index.ts # createApp() entry point
212
216
  config/
213
- server-config.ts # OPENALEX_API_KEY, OPENALEX_MAILTO, OPENALEX_BASE_URL
217
+ server-config.ts # OpenAlex API access and input compatibility switches
214
218
  services/
215
219
  openalex/
216
220
  openalex-service.ts # API client (init/accessor pattern)
217
221
  types.ts # Domain types
218
- filter-values.ts # Filter values as OpenAlex reads them: country/language codes, quoted multi-word names, `+` as AND
222
+ filter-values.ts # Native filter-string parsing and filter values: country/language codes, quoted phrases, `+` as AND
219
223
  iso-codes.json # ISO 3166-1 / 639-2 lookups (generated by scripts/generate-iso-codes.ts)
220
224
  provider-text.ts # Cleanup of provider text: entities, markup, MathType blobs
221
225
  html-entities.json # WHATWG named-entity table provider-text.ts decodes against
@@ -229,6 +233,7 @@ src/
229
233
  render-entity-record.ts # Entity records as content[] markdown
230
234
  render-budget.ts # The budget trailer line
231
235
  response-budget.ts # 64,000-byte response budget and its disclosures
236
+ page-size.ts # Configurable page-size validation and disclosed clamping
232
237
  tools/definitions/
233
238
  search-entities.tool.ts # openalex_search_entities
234
239
  analyze-trends.tool.ts # openalex_analyze_trends
package/CLAUDE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** openalex-mcp-server
4
- **Version:** 0.9.0
4
+ **Version:** 0.10.0
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
@@ -42,6 +42,8 @@ No resources — entity lookups need `select` for payload control, which fits to
42
42
  | `OPENALEX_API_KEY` | No | OpenAlex account API key, sent as `api_key=` (free from https://openalex.org/settings/api); omit for anonymous access |
43
43
  | `OPENALEX_MAILTO` | No | Email sent as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key |
44
44
  | `OPENALEX_BASE_URL` | No | Default: `https://api.openalex.org` |
45
+ | `OPENALEX_FILTER_STRING_INPUT` | No | Default: `true`; accept unambiguous native filter strings on `openalex_search_entities`; `false` restores record-only input. Strict boolean parsing rejects invalid values. |
46
+ | `OPENALEX_CLAMP_PER_PAGE` | No | Default: `true`; clamp positive integer `per_page` to 100 for search/citation and 200 for analysis, with a notice, log, and counter. `false` restores rejecting advertised maxima. Strict boolean parsing; semantic cap 50 and sample maxima still reject. |
45
47
 
46
48
  **Session mode:** `createApp({ sessionMode: 'stateless' })` in `src/index.ts` — no tool gates on `ctx.requestInput`, so every call completes in one round trip. An explicit `MCP_SESSION_MODE` still overrides it; `.env.example` and the `Dockerfile` set the same value. No service holds a watcher, socket, or ref'd timer, so there is no `teardown` hook.
47
49
 
@@ -189,6 +191,8 @@ async handler(input, ctx) {
189
191
 
190
192
  **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface — reading one tool definition file should give the full picture. Don't extract a shared `errors[]` constant; per-tool repetition is the intended cost of locality.
191
193
 
194
+ Native-filter parser failures raised during schema preprocessing carry explicit recovery guidance: the handler boundary has not run yet, so handler-contract hint filling cannot supply it.
195
+
192
196
  The OpenAlex service throws factory errors (`notFound`, `rateLimited`, etc.) based on upstream status codes, carrying the contract `reason` on `data.reason` and nothing else of the contract; the framework fills the calling tool's `recovery` for that reason at the handler boundary, and the errors bubble through and are auto-classified. Three service throws set their own `recovery: { hint }`, which wins over the fill: the PMCID 404, since the generic "verify the ID" recovery would send the caller back to retry an identifier OpenAlex never indexes; `upstream_budget_exhausted` on a server with no `OPENALEX_API_KEY`, whose hint adds the free-key advice to the contract's key-neutral midnight-UTC recovery (a keyed server's budget is already the larger one, so it gets the contract text alone); and `upstream_unavailable` on a semantic search — including the HTTP 400 `Failed to embed query:` and `Semantic search is not configured` bodies, relabelled from caller errors to that outage — whose hint adds the keyword-search fallback the generic outage recovery cannot name. One status is read past its code: an HTTP 429 whose body `error` is `Plan upgrade required` — a filter OpenAlex serves only on a paid plan — is thrown as `Forbidden` with `upstream_plan_required` and `retryable: false` under a message of the server's own, so it fails on its first request — no wait clears a plan gate. Every service-thrown entry carries `thrownBy: 'service'` — lint-only metadata that tells `error-contract-unthrown`, whose scan reads the handler body alone, to skip a reason produced a layer below it. Handler-local precondition throws stay inline and unmarked so the rule keeps checking them: `semantic_without_query`, `semantic_per_page_cap`, `semantic_with_cursor`, `page_without_semantic`, `sample_with_cursor`, `sample_with_page`, `sample_with_semantic`, `sample_with_sort`, `seed_without_sample`, `slice_without_id`, and `slice_field_not_array` on `openalex_search_entities`; `reserved_filter_key` and the seed-lookup `entity_not_found` on `openalex_get_citation_graph`; `filters_without_entity_type` on `openalex_resolve_name`. Recovery assertions belong on the contract path (`runToolContract`), where the fill applies — a direct `definition.handler(...)` or service throw carries only what the throw site wrote.
193
197
 
194
198
  **Fallback for ad-hoc throws:** error factories or plain `Error`.
@@ -210,12 +214,12 @@ See framework CLAUDE.md for the full auto-classification table and all available
210
214
  src/
211
215
  index.ts # createApp() entry point
212
216
  config/
213
- server-config.ts # OPENALEX_API_KEY, OPENALEX_MAILTO, OPENALEX_BASE_URL
217
+ server-config.ts # OpenAlex API access and input compatibility switches
214
218
  services/
215
219
  openalex/
216
220
  openalex-service.ts # API client (init/accessor pattern)
217
221
  types.ts # Domain types
218
- filter-values.ts # Filter values as OpenAlex reads them: country/language codes, quoted multi-word names, `+` as AND
222
+ filter-values.ts # Native filter-string parsing and filter values: country/language codes, quoted phrases, `+` as AND
219
223
  iso-codes.json # ISO 3166-1 / 639-2 lookups (generated by scripts/generate-iso-codes.ts)
220
224
  provider-text.ts # Cleanup of provider text: entities, markup, MathType blobs
221
225
  html-entities.json # WHATWG named-entity table provider-text.ts decodes against
@@ -229,6 +233,7 @@ src/
229
233
  render-entity-record.ts # Entity records as content[] markdown
230
234
  render-budget.ts # The budget trailer line
231
235
  response-budget.ts # 64,000-byte response budget and its disclosures
236
+ page-size.ts # Configurable page-size validation and disclosed clamping
232
237
  tools/definitions/
233
238
  search-entities.tool.ts # openalex_search_entities
234
239
  analyze-trends.tool.ts # openalex_analyze_trends
package/Dockerfile CHANGED
@@ -14,7 +14,7 @@
14
14
  # output. A stage that compiles a native addon needs the target-arch toolchain
15
15
  # and cannot cross-compile this way — drop the flag there.
16
16
  # ==============================================================================
17
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.3 AS build
17
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.4 AS build
18
18
 
19
19
  WORKDIR /usr/src/app
20
20
 
@@ -46,7 +46,7 @@ RUN bun run build
46
46
  # A clean image rather than `FROM build`: the build stage's node_modules holds
47
47
  # devDependencies.
48
48
  # ==============================================================================
49
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.3 AS deps
49
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.4 AS deps
50
50
 
51
51
  WORKDIR /usr/src/app
52
52
 
@@ -114,7 +114,7 @@ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
114
114
  # dependencies and build artifacts. Its only Bun invocations are HEALTHCHECK
115
115
  # and CMD, which run on the real target at container start.
116
116
  # ==============================================================================
117
- FROM oven/bun:1.4.3-slim AS production
117
+ FROM oven/bun:1.4.4-slim AS production
118
118
 
119
119
  WORKDIR /usr/src/app
120
120
 
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.9.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/openalex-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.3.1-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/openalex-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/openalex-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.3-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.10.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/openalex-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.3.1-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/openalex-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/openalex-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.4-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -54,13 +54,16 @@ Scholarly catalog data from [OpenAlex](https://openalex.org) — 270M+ works, 90
54
54
 
55
55
  - Retrieve a single entity by ID — OpenAlex ID, DOI, ORCID, ROR, PMID, ISSN, or PMCID (bare or URL form, including OpenAlex record-page and API URLs such as `https://openalex.org/works/W2741809807`), or a keyword by its slug or keyword URL. Under `entity_type: "funders"`, a funder's Crossref Funder Registry DOI (`10.13039/…`) resolves through a one-result list request. OpenAlex does not bill a lookup by ID, with three exceptions it bills like one list call: a keyword looked up by its slug or URL, an `id` carrying characters past the identifier itself (`W2741809807#x` still returns W2741809807), and that funder-DOI request. `id` takes precedence: search parameters passed alongside it are dropped, and the response names which ones. An OpenAlex ID whose letter names another entity type than `entity_type` fails as `id_type_mismatch` before any upstream call, and an `id` holding a `.` or `..` path segment, plain or percent-encoded, fails as `id_dot_segment`; a `?`, `#`, `\`, tab, or line break inside an identifier (SICI DOIs end in `#`) is sent percent-encoded as part of it. A PMCID resolves nothing (OpenAlex indexes none) — use the work's PMID or DOI instead
56
56
  - Keyword search (boolean operators, quoted phrases, wildcards, fuzzy match) plus `exact` 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 total
57
+ - `filters` 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 schema
58
+ - Filter values sent unquoted must have paired double quotes on all four filter-taking tools (`unmatched_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`)
57
59
  - Rich filter syntax: AND across fields, OR within a field (`|`), AND within a field (`+`), NOT (`!`), ranges, comparisons. One value cannot hold `|` beside 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 on a country field a space is always part of a country name, never AND: `DE US|FR` fails as `invalid_country_code`): OpenAlex splits a value on `|` first and drops every alternative that ANDs more than one value, so `DE|FR+US` and `authorships.author.id: A1 A2|A3` both 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). A comma inside a free-text search filter (any `.search` key — `title.search`, `abstract.search.exact`, `title_abstract_keywords.search`) or a field OpenAlex matches as whole text (listed below) is sent double-quoted as part of the phrase, while a comma in any other filter value fails as `comma_in_filter_value` (use `|` for OR); inside a `.search` value, a DOI, a date (a timestamp's `+00:00` offset), a URL (`landing_page_url`; not the boolean `has_pdf_url`), an exactly matched name (`display_name`, `awards.funder_display_name` — `C++`, `Erasmus+`), a `biblio` field (issue `1+2`), or `awards.funder_award_id`, a `+` is a literal character; a value with spaces on a field OpenAlex matches as whole text (`display_name`, `awards.funder_display_name`, `awards.funder_award_id`, `geo.city`, `geo.region`, `raw_affiliation_strings`, `block_key`, a `biblio` field) is sent double-quoted, so it matches as one phrase (`awards.funder_display_name: National Science Foundation`) — `!` and `|` work with it (`geo.city: New York|Boston`), but `+` cannot AND it with another value in the same field. A comma is part of such a value too (`raw_affiliation_strings: Associazione Meteonetwork OdV, Milano, Italy`): a value holding one goes out quoted with its `+` as a literal character. A double quote inside the outer pair of any value sent quoted this way, or of a value you wrap in double quotes on any filter but a free-text search, fails as `quote_in_filter_value`
58
60
  - Country and language values are converted to the codes OpenAlex stores, here and in every other tool that takes `filters`: on a country field (a key ending in `country_code`, `country_codes`, or `countries`), an ISO 3166-1 alpha-3 code, numeric code, or English name — ISO's, or the one OpenAlex's own country facets display (`Russia`, `Ivory Coast`, `Trinidad & Tobago`) — becomes its alpha-2 code (`DEU`, `276`, `Germany` → `DE`) and `UK` becomes `GB`, while a value that cannot be a country code (`Germanyy`, `999`, `unknown`, or two codes joined by a space, `DE US`) fails as `invalid_country_code` without the filter being sent; on `language`, an ISO 639-2 code becomes its ISO 639-1 code and an English name becomes its code (`eng`, `English` → `en`; `Cebuano` → `ceb`). Each `|`/`+` segment is trimmed and converted on its own, so a multi-word name is one value (`South Korea|Japan` → `KR|JP`, `Scottish Gaelic|English` → `gd|en`), and anything else — `null`, `XK`, `ceb`, `zh-cn`, the `countries/DEU` path form — is sent as given
59
61
  - `select` 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 `["*"]`
60
62
  - Cursor pagination for keyword and exact search, up to 100 results per page (default 25); semantic search walks its candidates with `page` (1-based) instead, and mixing either knob with the wrong `search_mode` is rejected before the upstream call
61
63
  - `sample` (up to 100, single page only — neither `cursor` nor `page` applies) plus a deterministic `seed` for reproducible random sampling; keyword and exact modes only, and never alongside `sort` — OpenAlex does not sample a semantic search and refuses a sorted sample, so both combinations are rejected before the upstream call
62
64
  - Each response holds to 64,000 bytes per surface (`structuredContent`, and `content[]` with its trailer) unless the least it can return is larger, which it flags as `over_budget`: a page that would run over keeps its leading records whole and names the rest in `omitted` with the one call that returns them (as a set, in OpenAlex's order), without disturbing `next_cursor`; a record too large on its own comes back with its largest arrays windowed, filled in turn so each shows what fits and labeled with how much of the array it holds, and `slice: {field, offset}` on an `id` lookup pages any array to the end
63
- - A list record carrying exactly 100 `authorships` or `author_list` entries — the most OpenAlex returns in a list response — is flagged as possibly capped, with the unbilled `id` + `slice` call that fetches the rest of that array
65
+ - A list record carrying exactly 100 `authorships` 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 ceiling
66
+ - Positive integer `per_page` values above 100 for search/citation or 200 for analysis clamp to the maximum, with requested/applied sizes in `notice`, a log, and a counter. Set `OPENALEX_CLAMP_PER_PAGE=false` to restore rejecting advertised maxima. Zero, negatives, fractions, semantic search above 50, and oversized samples still reject. An `id` ignores page size and `sample` overrides it
64
67
  - `display_name` is nullable for untitled records; every call reports OpenAlex daily-budget cost and remaining balance
65
68
 
66
69
  ---
@@ -68,8 +71,9 @@ Scholarly catalog data from [OpenAlex](https://openalex.org) — 270M+ works, 90
68
71
  ### `openalex_analyze_trends` <sub>tool</sub>
69
72
 
70
73
  - Group any supported field for trend, distribution, or comparative analysis; combine with `filters` to scope the population before aggregation
71
- - Up to 200 groups per page (default). `order: "count"` (default) returns the top-N by count with no further pages; `order: "key"` enumerates all distinct values key-ascending with cursor pagination
72
- - `include_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. 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 `false`
74
+ - Up to 200 groups per page (default). Without a cursor, `order: "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 present
75
+ - `include_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 `false`
76
+ - `key_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
73
77
  - Not every field is groupable — raw date fields, `.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 group
74
78
  - A works group_by on `authorships.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 them
75
79
  - Reports OpenAlex daily-budget cost and remaining balance — aggregation is priced far below paging the same entities
@@ -273,6 +277,8 @@ cp .env.example .env
273
277
  | `OPENALEX_API_KEY` | OpenAlex account API key, sent upstream as `api_key=` (free from [openalex.org/settings/api](https://openalex.org/settings/api)). Without it, anonymous rate limits apply. | — |
274
278
  | `OPENALEX_MAILTO` | Email sent upstream as `mailto=` to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from the API key. | — |
275
279
  | `OPENALEX_BASE_URL` | OpenAlex API base URL. | `https://api.openalex.org` |
280
+ | `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` |
281
+ | `OPENALEX_CLAMP_PER_PAGE` | Clamp oversized positive integer `per_page` with a requested/applied notice (100 search/citation, 200 analysis). Set `false` to restore rejecting advertised maxima; invalid boolean values fail startup. Semantic and sample caps still reject. | `true` |
276
282
  | `OTEL_ENABLED` | Enable [OpenTelemetry instrumentation](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry) (spans, metrics, completion logs). | `false` |
277
283
 
278
284
  See [`.env.example`](./.env.example) for the full list of optional overrides.
@@ -0,0 +1,30 @@
1
+ ---
2
+ summary: "Preserve nullable aggregation labels, correct filter handling, and disclose pagination and author-storage limits."
3
+ breaking: true
4
+ security: false
5
+ ---
6
+
7
+ # 0.10.0 — 2026-10-22
8
+
9
+ ## Changed
10
+
11
+ - Allow required `key_display_name` to be `null` in aggregation results, rendering the group key when its label is absent. (#119)
12
+ - **Native filter strings** are accepted by `openalex_search_entities`, with conversion disclosed in `notice`; set `OPENALEX_FILTER_STRING_INPUT=false` to require record objects. ([#118](https://github.com/cyanheads/openalex-mcp-server/issues/118))
13
+ - **Ordinary read pages** clamp oversized `per_page` to 100 for search/citation or 200 for analysis, with requested/applied notices; `OPENALEX_CLAMP_PER_PAGE=false` restores rejection. Semantic search retains its 50-result cap; `id` and `sample` take precedence. ([#121](https://github.com/cyanheads/openalex-mcp-server/issues/121))
14
+ - **Bun** 1.4.3 → 1.4.4 for package management, container images, and the README badge.
15
+ - **Regression coverage** checks filter scanner growth and stored author sequences; entity-like Markdown escaping is measured with thread CPU time.
16
+
17
+ ## Fixed
18
+
19
+ - **Aggregation ordering** notices and echoes follow supplied cursors and the boolean-field count-order exception. ([#41](https://github.com/cyanheads/openalex-mcp-server/issues/41))
20
+ - **Unknown aggregation groups** are flagged when `group_by` carries `:include_unknown`; combining the suffix and boolean sends the suffix once. ([#75](https://github.com/cyanheads/openalex-mcp-server/issues/75))
21
+ - **Key-ordered pagination** warns that OpenAlex can wrap and directs callers to stop on a repeated first key or a short/empty page. ([#108](https://github.com/cyanheads/openalex-mcp-server/issues/108))
22
+ - **Unmatched filter quotes** fail before any upstream request on all four filter-taking tools, including the citation seed lookup, with recovery guidance. ([#116](https://github.com/cyanheads/openalex-mcp-server/issues/116))
23
+ - **Author windows** disclose OpenAlex's 5,000-author storage ceiling on singleton lookups and slices, retaining `possibly_capped:true` while ending continuation at the stored-array boundary. ([#120](https://github.com/cyanheads/openalex-mcp-server/issues/120))
24
+
25
+ ## Dependencies
26
+
27
+ - `ignore` ^7.0.12 → ^7.0.13.
28
+ - `tsc-alias` ^1.9.7 → ^1.9.8.
29
+ - `vitest` ^5.0.4 → ^5.0.5.
30
+ - Lockfile: `postcss` 8.5.30 → 8.5.31.
@@ -7,6 +7,8 @@ declare const ServerConfigSchema: z.ZodObject<{
7
7
  apiKey: z.ZodDefault<z.ZodString>;
8
8
  mailto: z.ZodDefault<z.ZodString>;
9
9
  baseUrl: z.ZodDefault<z.ZodString>;
10
+ filterStringInput: z.ZodDefault<z.ZodCodec<z.ZodString, z.ZodBoolean>>;
11
+ clampPerPage: z.ZodDefault<z.ZodCodec<z.ZodString, z.ZodBoolean>>;
10
12
  }, z.core.$strip>;
11
13
  export type ServerConfig = z.infer<typeof ServerConfigSchema>;
12
14
  export declare function getServerConfig(): ServerConfig;
@@ -1 +1 @@
1
- {"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAG3C,QAAA,MAAM,kBAAkB;;;;iBActB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAO9C"}
1
+ {"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAG3C,QAAA,MAAM,kBAAkB;;;;;;iBAsBtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAS9C"}
@@ -14,6 +14,14 @@ const ServerConfigSchema = z.object({
14
14
  .default('')
15
15
  .describe('Optional email sent upstream as mailto= to identify yourself to OpenAlex (the "polite pool"); a courtesy identifier, separate from and not a substitute for the API key'),
16
16
  baseUrl: z.string().url().default('https://api.openalex.org').describe('OpenAlex API base URL'),
17
+ filterStringInput: z
18
+ .stringbool()
19
+ .default(true)
20
+ .describe('Accept unambiguous native filter strings on openalex_search_entities'),
21
+ clampPerPage: z
22
+ .stringbool()
23
+ .default(true)
24
+ .describe('Clamp oversized per_page values to each tool maximum, with a notice'),
17
25
  });
18
26
  let _config;
19
27
  export function getServerConfig() {
@@ -21,6 +29,8 @@ export function getServerConfig() {
21
29
  apiKey: 'OPENALEX_API_KEY',
22
30
  mailto: 'OPENALEX_MAILTO',
23
31
  baseUrl: 'OPENALEX_BASE_URL',
32
+ filterStringInput: 'OPENALEX_FILTER_STRING_INPUT',
33
+ clampPerPage: 'OPENALEX_CLAMP_PER_PAGE',
24
34
  });
25
35
  return _config;
26
36
  }
@@ -1 +1 @@
1
- {"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CACP,sMAAsM,CACvM;IACH,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CACP,yKAAyK,CAC1K;IACH,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,0BAA0B,CAAC,CAAC,QAAQ,CAAC,uBAAuB,CAAC;CAChG,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,MAAM,EAAE,kBAAkB;QAC1B,MAAM,EAAE,iBAAiB;QACzB,OAAO,EAAE,mBAAmB;KAC7B,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC"}
1
+ {"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CACP,sMAAsM,CACvM;IACH,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CACP,yKAAyK,CAC1K;IACH,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,0BAA0B,CAAC,CAAC,QAAQ,CAAC,uBAAuB,CAAC;IAC/F,iBAAiB,EAAE,CAAC;SACjB,UAAU,EAAE;SACZ,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,sEAAsE,CAAC;IACnF,YAAY,EAAE,CAAC;SACZ,UAAU,EAAE;SACZ,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,qEAAqE,CAAC;CACnF,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,MAAM,EAAE,kBAAkB;QAC1B,MAAM,EAAE,iBAAiB;QACzB,OAAO,EAAE,mBAAmB;QAC5B,iBAAiB,EAAE,8BAA8B;QACjD,YAAY,EAAE,yBAAyB;KACxC,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC"}
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ await createApp({
25
25
  describeFieldsTool,
26
26
  ],
27
27
  prompts: [literatureReviewPrompt, researchLandscapePrompt],
28
- instructions: "Use the openalex_* tools to query the OpenAlex scholarly catalog (works, authors, sources, institutions, topics, keywords, publishers, funders): resolve names to IDs, search/filter/sort or fetch by ID, and group_by for trends. Names are ambiguous and IDs are not — call openalex_resolve_name before filtering by entity. Record responses hold to 64,000 bytes per surface: a cut page names its `omitted` IDs and the call that returns them (in OpenAlex's order), and an array too long to fit comes back as a window that an `id` lookup pages with `slice`.",
28
+ instructions: "Use the openalex_* tools to query the OpenAlex scholarly catalog (works, authors, sources, institutions, topics, keywords, publishers, funders): resolve names to IDs, search/filter/sort or fetch by ID, and group_by for trends. Names are ambiguous and IDs are not — call openalex_resolve_name before filtering by entity. Record responses hold to 64,000 bytes per surface: a cut page names its `omitted` IDs and the call that returns them (in OpenAlex's order), and an array too long to fit comes back as a window that an `id` lookup pages with `slice`. Author arrays may be capped at 100 on list records or 5,000 on singleton records and snapshots; `total` counts stored entries, and `next` is null once those entries are exhausted even when the byline may be longer. Oversized positive integer `per_page` values clamp with a notice to 100 for search/citation or 200 for analysis unless OPENALEX_CLAMP_PER_PAGE=false restores rejection. Semantic search above 50 and oversized samples still reject.",
29
29
  landing: {
30
30
  tagline: 'Search the OpenAlex catalog — 270M+ works, 90M+ authors, 100K+ sources.',
31
31
  requireAuth: false,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,8DAA8D,CAAC;AACtG,OAAO,EAAE,uBAAuB,EAAE,MAAM,+DAA+D,CAAC;AACxG,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,uDAAuD,CAAC;AAC7F,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,eAAe,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,qBAAqB;IAC3B,KAAK,EAAE,qBAAqB;IAC5B,qFAAqF;IACrF,WAAW,EAAE,WAAW;IACxB,KAAK,EAAE;QACL,eAAe;QACf,kBAAkB;QAClB,iBAAiB;QACjB,oBAAoB;QACpB,kBAAkB;KACnB;IACD,OAAO,EAAE,CAAC,sBAAsB,EAAE,uBAAuB,CAAC;IAC1D,YAAY,EACV,yiBAAyiB;IAC3iB,OAAO,EAAE;QACP,OAAO,EAAE,yEAAyE;QAClF,WAAW,EAAE,KAAK;QAClB,QAAQ,EAAE,kDAAkD;QAC5D,UAAU,EAAE;YACV,gBAAgB,EAAE,uBAAuB;SAC1C;QACD,KAAK,EAAE;YACL,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,sBAAsB,EAAE;YACnD,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,iCAAiC,EAAE;SAC/D;KACF;IACD,KAAK;QACH,mBAAmB,EAAE,CAAC;IACxB,CAAC;CACF,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,8DAA8D,CAAC;AACtG,OAAO,EAAE,uBAAuB,EAAE,MAAM,+DAA+D,CAAC;AACxG,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,uDAAuD,CAAC;AAC7F,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,eAAe,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,qBAAqB;IAC3B,KAAK,EAAE,qBAAqB;IAC5B,qFAAqF;IACrF,WAAW,EAAE,WAAW;IACxB,KAAK,EAAE;QACL,eAAe;QACf,kBAAkB;QAClB,iBAAiB;QACjB,oBAAoB;QACpB,kBAAkB;KACnB;IACD,OAAO,EAAE,CAAC,sBAAsB,EAAE,uBAAuB,CAAC;IAC1D,YAAY,EACV,s+BAAs+B;IACx+B,OAAO,EAAE;QACP,OAAO,EAAE,yEAAyE;QAClF,WAAW,EAAE,KAAK;QAClB,QAAQ,EAAE,kDAAkD;QAC5D,UAAU,EAAE;YACV,gBAAgB,EAAE,uBAAuB;SAC1C;QACD,KAAK,EAAE;YACL,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,sBAAsB,EAAE;YACnD,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,iCAAiC,EAAE;SAC/D;KACF;IACD,KAAK;QACH,mBAAmB,EAAE,CAAC;IACxB,CAAC;CACF,CAAC,CAAC"}
@@ -18,7 +18,7 @@ export declare const analyzeTrendsTool: import("@cyanheads/mcp-ts-core").ToolDef
18
18
  group_by: z.ZodString;
19
19
  filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
20
20
  include_unknown: z.ZodDefault<z.ZodBoolean>;
21
- per_page: z.ZodDefault<z.ZodNumber>;
21
+ per_page: z.ZodDefault<z.ZodLazy<z.ZodNumber>>;
22
22
  order: z.ZodOptional<z.ZodEnum<{
23
23
  count: "count";
24
24
  key: "key";
@@ -32,11 +32,17 @@ export declare const analyzeTrendsTool: import("@cyanheads/mcp-ts-core").ToolDef
32
32
  }, z.core.$strip>;
33
33
  groups: z.ZodArray<z.ZodObject<{
34
34
  key: z.ZodString;
35
- key_display_name: z.ZodString;
35
+ key_display_name: z.ZodNullable<z.ZodString>;
36
36
  count: z.ZodNumber;
37
37
  is_unknown: z.ZodOptional<z.ZodLiteral<true>>;
38
38
  }, z.core.$strip>>;
39
39
  }, z.core.$strip>, readonly [{
40
+ readonly reason: "unmatched_quote_in_filter_value";
41
+ readonly code: JsonRpcErrorCode.ValidationError;
42
+ readonly when: "A filter value sent unquoted has an unmatched double quote.";
43
+ readonly recovery: "Pair double quotes used for a search phrase, or remove the stray quote.";
44
+ readonly thrownBy: "service";
45
+ }, {
40
46
  readonly reason: "rate_limited";
41
47
  readonly code: JsonRpcErrorCode.RateLimited;
42
48
  readonly when: "OpenAlex throttled the request for exceeding its per-second ceiling (HTTP 429).";
@@ -1 +1 @@
1
- {"version":3,"file":"analyze-trends.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/analyze-trends.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAWjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2Y5B,CAAC"}
1
+ {"version":3,"file":"analyze-trends.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/analyze-trends.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAgBjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkZ5B,CAAC"}
@@ -5,17 +5,27 @@
5
5
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
6
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { escapeMarkdown } from '../escape-markdown.js';
8
+ import { clampPageSize, pageSizeSchema } from '../page-size.js';
8
9
  import { renderBudgetTrailer } from '../render-budget.js';
9
10
  import { joinNotices } from '../response-budget.js';
10
- import { getOpenAlexService } from '../../../services/openalex/openalex-service.js';
11
+ import { getOpenAlexService, isBooleanGroupBy } from '../../../services/openalex/openalex-service.js';
11
12
  import { ENTITY_TYPES } from '../../../services/openalex/types.js';
12
13
  /** Disclosure for a works group_by over `authorships.countries` (see the handler). */
13
14
  const COUNTRIES_CAP_NOTICE = 'OpenAlex builds authorships.countries groups from only the first 100 authorships of each work, so a country that appears only past position 100 on a large collaboration is not counted. Group by authorships.institutions.country_code to count every authorship.';
15
+ /** Upstream cursors can wrap even after a short page; callers own the stopping decision. */
16
+ const KEY_ORDER_GUIDANCE = "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.";
14
17
  export const analyzeTrendsTool = tool('openalex_analyze_trends', {
15
- description: '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 cursor pagination for fields with many distinct values.',
18
+ description: `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. ${KEY_ORDER_GUIDANCE}`,
16
19
  sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/analyze-trends.tool.ts',
17
20
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
18
21
  errors: [
22
+ {
23
+ reason: 'unmatched_quote_in_filter_value',
24
+ code: JsonRpcErrorCode.ValidationError,
25
+ when: 'A filter value sent unquoted has an unmatched double quote.',
26
+ recovery: 'Pair double quotes used for a search phrase, or remove the stray quote.',
27
+ thrownBy: 'service',
28
+ },
19
29
  {
20
30
  reason: 'rate_limited',
21
31
  code: JsonRpcErrorCode.RateLimited,
@@ -148,31 +158,27 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
148
158
  group_by: z
149
159
  .string()
150
160
  .min(1)
151
- .describe('Field to group by. Works examples: "publication_year", "type", "oa_status", "primary_topic.field.id", "authorships.institutions.country_code", "is_retracted". 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". Not all fields support group_by — call openalex_describe_fields(entity_type, "group_by") for the groupable set.'),
161
+ .describe('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". Not all fields support group_by — call openalex_describe_fields(entity_type, "group_by") for the groupable set.'),
152
162
  filters: z
153
163
  .record(z.string(), z.string())
154
164
  .optional()
155
- .describe('Filter criteria (same syntax as openalex_search_entities filters). 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 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.'),
165
+ .describe('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.'),
156
166
  include_unknown: z
157
167
  .boolean()
158
168
  .default(false)
159
- .describe('Add a group for entities with no value for the grouped field. Hidden by default. 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.'),
160
- per_page: z
161
- .number()
162
- .int()
163
- .min(1)
164
- .max(200)
169
+ .describe('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.'),
170
+ per_page: pageSizeSchema(200)
165
171
  .default(200)
166
- .describe('Maximum groups per page (1-200). Default 200 (the upstream cap). A real top-N knob when order is count (the default) — reduce to return only the highest-count groups.'),
172
+ .describe('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.'),
167
173
  order: z
168
174
  .enum(['count', 'key'])
169
175
  .optional()
170
- .describe('Sort order for groups. Omit or pass "count" (default) to return the top-N groups by count descending — no further pages. Pass "key" to enumerate all distinct values in key-ascending order with cursor pagination. Use "key" only when you need a full traversal; most analysis calls want "count".'),
176
+ .describe(`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". ${KEY_ORDER_GUIDANCE}`),
171
177
  cursor: z
172
178
  .string()
173
179
  .min(1)
174
180
  .optional()
175
- .describe('Pagination cursor from a previous response. Only relevant when order is "key" — count-descending results have no next page. Pass the next_cursor from the previous response to advance. 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.'),
181
+ .describe(`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. ${KEY_ORDER_GUIDANCE}`),
176
182
  }),
177
183
  output: z.object({
178
184
  meta: z
@@ -182,7 +188,7 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
182
188
  next_cursor: z
183
189
  .string()
184
190
  .nullable()
185
- .describe('Cursor for next page of groups. null if no more groups.'),
191
+ .describe(`Upstream continuation cursor; null when OpenAlex supplies none. A non-null cursor does not guarantee unseen groups. ${KEY_ORDER_GUIDANCE}`),
186
192
  })
187
193
  .describe('Aggregation metadata.'),
188
194
  groups: z
@@ -193,7 +199,8 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
193
199
  .describe('Group key (OpenAlex ID or raw value), exactly as OpenAlex returns it.'),
194
200
  key_display_name: z
195
201
  .string()
196
- .describe('Human-readable group label as plain text, with HTML entities decoded and markup removed.'),
202
+ .nullable()
203
+ .describe('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.'),
197
204
  count: z.number().describe('Number of entities in this group.'),
198
205
  is_unknown: z
199
206
  .literal(true)
@@ -209,14 +216,14 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
209
216
  enrichment: {
210
217
  echo: z
211
218
  .string()
212
- .describe('Compact echo of the input criteria (entity_type, group_by, filters) — surfaces what was actually requested when no groups are returned.'),
219
+ .describe('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.'),
213
220
  totalCount: z
214
221
  .number()
215
222
  .describe('Total entities matching the filters before grouping (across all pages).'),
216
223
  notice: z
217
224
  .string()
218
225
  .optional()
219
- .describe('Guidance notice. Set when a first call returns no groups (recovery suggestions), when a `cursor` continuation returns none because the traversal is already finished, when the 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.'),
226
+ .describe('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.'),
220
227
  budget: z
221
228
  .object({
222
229
  costUsd: z
@@ -240,13 +247,14 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
240
247
  budget: { render: renderBudgetTrailer },
241
248
  },
242
249
  async handler(input, ctx) {
250
+ const pageSize = clampPageSize(input.per_page, 200, 'openalex_analyze_trends', ctx);
243
251
  const service = getOpenAlexService();
244
252
  const result = await service.analyze({
245
253
  entityType: input.entity_type,
246
254
  groupBy: input.group_by,
247
255
  filters: input.filters,
248
256
  includeUnknown: input.include_unknown,
249
- perPage: input.per_page,
257
+ perPage: pageSize.applied,
250
258
  order: input.order,
251
259
  cursor: input.cursor,
252
260
  }, ctx);
@@ -256,10 +264,13 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
256
264
  totalCount: result.meta.count,
257
265
  groupCount: result.groups.length,
258
266
  });
259
- const echo = buildAnalyzeEcho(input);
267
+ const keyOrder = input.cursor !== undefined || (input.order === 'key' && !isBooleanGroupBy(input.group_by));
268
+ const echo = buildAnalyzeEcho(input, keyOrder);
260
269
  ctx.enrich({ echo, totalCount: result.meta.count });
261
270
  // `notice` is last-wins, so every sentence is collected and set once.
262
271
  const notices = [];
272
+ if (pageSize.notice)
273
+ notices.push(pageSize.notice);
263
274
  // An empty page on a `cursor` continuation means the key-ascending traversal already
264
275
  // enumerated every group, so there is nothing left to remove or regroup. On a first call
265
276
  // the same shape can still be an honest zero — including the all-values-unknown case,
@@ -269,16 +280,13 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
269
280
  ? `No groups returned for ${echo}. Try removing filters or grouping by a different field.`
270
281
  : `Pagination exhausted for ${echo} — the previous page held the last group, so this one came back empty. Stop paging rather than removing filters or regrouping.`);
271
282
  }
272
- else if (input.order === 'key') {
273
- // Key-ascending traversal: omitted groups sit beyond the cursor, not in a count tail, so
274
- // the count-bound notice below would be false here (it would name the smallest keys as the
275
- // "top by count"). The honest signal is whether OpenAlex returned a next_cursor; page-fill
276
- // is irrelevant in this mode.
277
- if (result.meta.next_cursor) {
278
- notices.push(`Showing ${result.groups.length} groups in key-ascending order. Pass the returned \`next_cursor\` to continue the traversal.`);
279
- }
283
+ if (keyOrder) {
284
+ // A cursor selects key order regardless of `order`; upstream can wrap without suppressing it.
285
+ notices.push(result.groups.length === 0
286
+ ? KEY_ORDER_GUIDANCE
287
+ : `Showing ${result.groups.length} groups in key-ascending order. ${KEY_ORDER_GUIDANCE}${result.meta.next_cursor ? ' Otherwise, pass the returned `next_cursor` to continue the traversal.' : ''}`);
280
288
  }
281
- else if (result.groups.length === input.per_page) {
289
+ else if (result.groups.length > 0 && result.groups.length === pageSize.applied) {
282
290
  // Count-desc (the default): the page is filled to the limit, so more distinct groups likely
283
291
  // exist. The omitted groups all have counts ≤ the smallest group shown, so we can bound the
284
292
  // gap even though OpenAlex exposes no total group count.
@@ -308,11 +316,14 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
308
316
  },
309
317
  format: (result) => {
310
318
  const heading = `${result.meta.count} total entities across ${result.meta.groups_count ?? result.groups.length} groups on this page`;
319
+ const footer = result.meta.next_cursor
320
+ ? `\n\nUpstream next_cursor: \`${result.meta.next_cursor}\`. ${KEY_ORDER_GUIDANCE}`
321
+ : '';
311
322
  if (result.groups.length === 0) {
312
323
  return [
313
324
  {
314
325
  type: 'text',
315
- text: `No groups found. (count=${result.meta.count}, groups_count=${result.meta.groups_count ?? 0})`,
326
+ text: `No groups found. (count=${result.meta.count}, groups_count=${result.meta.groups_count ?? 0})${footer}`,
316
327
  },
317
328
  ];
318
329
  }
@@ -327,9 +338,6 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
327
338
  ]
328
339
  : result.groups;
329
340
  const lines = renderOrder.map((g) => `${renderGroupLabel(g)}: ${g.count}`);
330
- const footer = result.meta.next_cursor
331
- ? `\n\n*More groups available — next_cursor: \`${result.meta.next_cursor}\`*`
332
- : '';
333
341
  return [
334
342
  {
335
343
  type: 'text',
@@ -351,23 +359,30 @@ function isTimeSeriesGrouping(groups) {
351
359
  function renderGroupLabel(g) {
352
360
  const key = escapeMarkdown(g.key);
353
361
  if (g.is_unknown) {
354
- const redundant = g.key_display_name === g.key || g.key_display_name.toLowerCase() === 'unknown';
355
- const label = redundant ? '' : `, label ${escapeMarkdown(g.key_display_name)}`;
362
+ const label = g.key_display_name === null ||
363
+ g.key_display_name === g.key ||
364
+ g.key_display_name.toLowerCase() === 'unknown'
365
+ ? ''
366
+ : `, label ${escapeMarkdown(g.key_display_name)}`;
356
367
  return `unknown (no value; key ${key}${label})`;
357
368
  }
369
+ if (g.key_display_name === null)
370
+ return escapeMarkdown(g.key, 'line-start');
358
371
  const display = escapeMarkdown(g.key_display_name, 'line-start');
359
372
  return g.key === g.key_display_name ? display : `${display} (${key})`;
360
373
  }
361
374
  /** The request as one line: caller text is JSON-encoded, so a line break in it stays escaped. */
362
- function buildAnalyzeEcho(input) {
375
+ function buildAnalyzeEcho(input, keyOrder) {
363
376
  const parts = [`entity_type=${input.entity_type}`, `group_by=${JSON.stringify(input.group_by)}`];
364
377
  if (input.filters && Object.keys(input.filters).length > 0) {
365
378
  parts.push(`filters=${JSON.stringify(input.filters)}`);
366
379
  }
367
- if (input.include_unknown)
380
+ if (input.include_unknown || input.group_by.endsWith(':include_unknown'))
368
381
  parts.push('include_unknown=true');
369
- if (input.order)
370
- parts.push(`order=${input.order}`);
382
+ if (keyOrder)
383
+ parts.push('order=key');
384
+ else if (input.order)
385
+ parts.push('order=count');
371
386
  return parts.join(' | ');
372
387
  }
373
388
  //# sourceMappingURL=analyze-trends.tool.js.map