@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.
- package/AGENTS.md +8 -3
- package/CLAUDE.md +8 -3
- package/Dockerfile +3 -3
- package/README.md +10 -4
- package/changelog/0.10.x/0.10.0.md +30 -0
- package/dist/config/server-config.d.ts +2 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +10 -0
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +8 -2
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +53 -38
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +7 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +17 -12
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +8 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +23 -2
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +70 -15
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/page-size.d.ts +18 -0
- package/dist/mcp-server/tools/page-size.d.ts.map +1 -0
- package/dist/mcp-server/tools/page-size.js +28 -0
- package/dist/mcp-server/tools/page-size.js.map +1 -0
- package/dist/mcp-server/tools/response-budget.d.ts +3 -4
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/response-budget.js +46 -26
- package/dist/mcp-server/tools/response-budget.js.map +1 -1
- package/dist/services/openalex/filter-values.d.ts +2 -0
- package/dist/services/openalex/filter-values.d.ts.map +1 -1
- package/dist/services/openalex/filter-values.js +56 -0
- package/dist/services/openalex/filter-values.js.map +1 -1
- package/dist/services/openalex/openalex-service.d.ts +6 -2
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +15 -9
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/types.d.ts +5 -2
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/package.json +5 -5
- 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.
|
|
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 #
|
|
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 #
|
|
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.
|
|
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 #
|
|
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 #
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/openalex-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/@cyanheads/openalex-mcp-server) [](https://www.typescriptlang.org/) [](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
|
|
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
|
|
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
|
|
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;
|
|
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,
|
|
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;
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 ===
|
|
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
|
|
355
|
-
|
|
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 (
|
|
370
|
-
parts.push(
|
|
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
|