@cyanheads/openalex-mcp-server 0.7.14 → 0.7.16

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 (49) hide show
  1. package/AGENTS.md +8 -4
  2. package/CLAUDE.md +8 -4
  3. package/README.md +12 -9
  4. package/changelog/0.7.x/0.7.15.md +33 -0
  5. package/changelog/0.7.x/0.7.16.md +32 -0
  6. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  7. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +22 -2
  9. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +71 -17
  11. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +12 -1
  13. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +56 -39
  15. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/describe-fields.tool.js +82 -15
  18. package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +18 -0
  20. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +29 -7
  22. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +50 -0
  24. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/search-entities.tool.js +176 -41
  26. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/escape-markdown.d.ts +32 -0
  28. package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -0
  29. package/dist/mcp-server/tools/escape-markdown.js +389 -0
  30. package/dist/mcp-server/tools/escape-markdown.js.map +1 -0
  31. package/dist/mcp-server/tools/render-entity-record.d.ts +2 -1
  32. package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/render-entity-record.js +24 -8
  34. package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
  35. package/dist/services/openalex/html-entities.json +2237 -0
  36. package/dist/services/openalex/openalex-service.d.ts +17 -1
  37. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  38. package/dist/services/openalex/openalex-service.js +211 -101
  39. package/dist/services/openalex/openalex-service.js.map +1 -1
  40. package/dist/services/openalex/provider-text.d.ts +20 -0
  41. package/dist/services/openalex/provider-text.d.ts.map +1 -0
  42. package/dist/services/openalex/provider-text.js +276 -0
  43. package/dist/services/openalex/provider-text.js.map +1 -0
  44. package/dist/services/openalex/types.d.ts +16 -0
  45. package/dist/services/openalex/types.d.ts.map +1 -1
  46. package/dist/services/openalex/types.js +7 -0
  47. package/dist/services/openalex/types.js.map +1 -1
  48. package/package.json +6 -3
  49. package/server.json +3 -3
@@ -25,6 +25,7 @@ export declare const PMCID_NOT_INDEXED_HINT = "OpenAlex indexes no PMCIDs, so a
25
25
  * "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/" → "pmcid:PMC1234567"
26
26
  * "https://pubmed.ncbi.nlm.nih.gov/21491125" → "pmid:21491125"
27
27
  * "PMID:21491125" → "pmid:21491125"
28
+ * "https://openalex.org/keywords/groundwater" → "groundwater"
28
29
  * "W2741809807" → "W2741809807"
29
30
  */
30
31
  export declare function normalizeId(id: string): string;
@@ -43,6 +44,11 @@ export interface ResolvedIdentifier {
43
44
  * and stamps external ones with their scheme prefix — so detection reduces to reading back what
44
45
  * it produced. Names never survive that: a name with a colon in it yields a prefix that is in no
45
46
  * scheme table, and anything else fails the native-ID pattern.
47
+ *
48
+ * Keywords are the exception, and are read from the raw query instead: their IDs are slugs, so
49
+ * `normalizeId()` reduces the URL to a bare word that no longer names an entity type. The URL
50
+ * form is the only keyword spelling that identifies itself — a bare slug is indistinguishable
51
+ * from any other name, and autocomplete is the right answer for it.
46
52
  */
47
53
  export declare function inferIdentifier(query: string): ResolvedIdentifier | undefined;
48
54
  /**
@@ -52,6 +58,16 @@ export declare function inferIdentifier(query: string): ResolvedIdentifier | und
52
58
  * a work has no works of its own, which is the same thing autocomplete reports.
53
59
  */
54
60
  export declare function toAutocompleteRecord(entityType: EntityType, record: EntityRecord): AutocompleteRecord;
61
+ /**
62
+ * Rewrite one caller-supplied filter key to the upstream OpenAlex name, before the
63
+ * value-dependent `id` rewrite. Exported so the citation-graph tool can check a caller's key
64
+ * against the ones `direction` reserves: checking the raw key alone let `cited_works` past the
65
+ * guard, and it then aliased onto `cites` and collided with the direction's own value.
66
+ *
67
+ * Misses fall through unchanged so upstream's 400 with the valid-field list still fires for
68
+ * typos no map can preempt.
69
+ */
70
+ export declare function translateFilterKey(entityType: SearchParams['entityType'], key: string): string;
55
71
  declare class OpenAlexService {
56
72
  private readonly baseUrl;
57
73
  private readonly apiKey;
@@ -88,7 +104,7 @@ export declare function getOpenAlexService(): OpenAlexService;
88
104
  * Return the typed field catalog keyed by entity type → context → field list.
89
105
  * Only `filter` and `select` arrays are stored. `group_by` resolves to the `filter` key here;
90
106
  * the describe-fields handler then prunes the fields group_by cannot target (raw dates,
91
- * `*.search` operators, and `from_*`/`to_*` range modifiers).
107
+ * `*.search` operators, `from_*`/`to_*` range modifiers, and a per-type set from a live sweep).
92
108
  */
93
109
  export declare function getFieldCatalog(): Record<EntityType, {
94
110
  filter: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"openalex-service.d.ts","sourceRoot":"","sources":["../../../src/services/openalex/openalex-service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAsBtD,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EAGvB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,YAAY,CAAC;AAiFpB;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,8OACgM,CAAC;AAgBpO;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAgF9C;AAsBD,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,UAAU,CAAC;IACvB,gGAAgG;IAChG,EAAE,EAAE,MAAM,CAAC;IACX,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAa7E;AAuJD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,GACnB,kBAAkB,CAUpB;AAmeD,cAAM,eAAe;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,cAKC;IAED,yFAAyF;IACzF,OAAO,CAAC,OAAO;IAgDf,OAAO,CAAC,2BAA2B;IA2EnC,OAAO,CAAC,aAAa;IAqCrB,sEAAsE;IAChE,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,CAAC,CA0FtE;IAED,4BAA4B;IACtB,OAAO,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAkDzE;IAED;;;;;;;;;;;;OAYG;IACG,iBAAiB,CACrB,UAAU,EAAE,kBAAkB,EAC9B,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,kBAAkB,CAAC,CAkB7B;IAED,oCAAoC;IAC9B,YAAY,CAAC,MAAM,EAAE,kBAAkB,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAsBxF;CACF;AAID,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAED,wBAAgB,kBAAkB,IAAI,eAAe,CAIpD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAAC,UAAU,EAAE;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAE5F"}
1
+ {"version":3,"file":"openalex-service.d.ts","sourceRoot":"","sources":["../../../src/services/openalex/openalex-service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAuBtD,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EAGvB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,YAAY,CAAC;AAwFpB;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,8OACgM,CAAC;AA0BpO;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAuF9C;AAuBD,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,UAAU,CAAC;IACvB,gGAAgG;IAChG,EAAE,EAAE,MAAM,CAAC;IACX,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAkB7E;AA4HD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,GACnB,kBAAkB,CAUpB;AAoHD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,YAAY,CAAC,YAAY,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE9F;AAybD,cAAM,eAAe;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,cAKC;IAED,yFAAyF;IACzF,OAAO,CAAC,OAAO;IAgDf,OAAO,CAAC,2BAA2B;IAkGnC,OAAO,CAAC,aAAa;IAqCrB,sEAAsE;IAChE,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,CAAC,CAgGtE;IAED,4BAA4B;IACtB,OAAO,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAkEzE;IAED;;;;;;;;;;;;OAYG;IACG,iBAAiB,CACrB,UAAU,EAAE,kBAAkB,EAC9B,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,kBAAkB,CAAC,CAkB7B;IAED,oCAAoC;IAC9B,YAAY,CAAC,MAAM,EAAE,kBAAkB,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAsBxF;CACF;AAID,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAED,wBAAgB,kBAAkB,IAAI,eAAe,CAIpD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAAC,UAAU,EAAE;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAE5F"}
@@ -8,6 +8,7 @@ import { getServerConfig } from '../../config/server-config.js';
8
8
  import { mergeUpstreamBudget, parseUpstreamBudget } from './budget.js';
9
9
  import fieldCatalog from './field-catalog.json' with { type: 'json' };
10
10
  import { rankFields } from './field-ranker.js';
11
+ import { normalizeProviderText, normalizeProviderValue } from './provider-text.js';
11
12
  import { DEFAULT_SELECT, ENTITY_TYPES, } from './types.js';
12
13
  import { redactUrlsInMessage } from './url-redaction.js';
13
14
  /**
@@ -27,17 +28,21 @@ const BOOLEAN_GROUP_BY_FIELDS = new Set([
27
28
  'has_fulltext',
28
29
  ]);
29
30
  /**
30
- * Build an OpenAlex filter string from a key-value record.
31
- * Input: { "cited_by_count": ">100", "is_oa": "true" }
31
+ * Build an OpenAlex filter string from ordered key-value clauses.
32
+ * Input: [["cited_by_count", ">100"], ["is_oa", "true"]]
32
33
  * Output: "cited_by_count:>100,is_oa:true"
33
34
  *
35
+ * Clauses, not a record: OpenAlex ANDs a repeated key (`publication_year:2020,publication_year:2024`
36
+ * matches works in both years, which is nothing), and two caller filters can resolve to one
37
+ * canonical key. A record would hold only the last of them.
38
+ *
34
39
  * Comma handling (commas collide with OpenAlex's filter clause separator):
35
40
  * - `*.search` keys: wrap the value in double quotes for faithful phrase passthrough
36
41
  * (skip if already quoted).
37
42
  * - All other keys: throw a pre-flight validation error — OpenAlex OR-lists use `|`, not commas.
38
43
  */
39
44
  function buildFilterString(filters, ctx) {
40
- return Object.entries(filters)
45
+ return filters
41
46
  .map(([key, value]) => {
42
47
  if (value.includes(',')) {
43
48
  if (key.endsWith('.search')) {
@@ -87,6 +92,15 @@ const PMC_URL_PATTERN = /^https?:\/\/(?:www\.)?(?:pmc\.ncbi\.nlm\.nih\.gov|ncbi\
87
92
  * mapping here and the `openalex_resolve_name` miss notice so both say the same thing.
88
93
  */
89
94
  export const PMCID_NOT_INDEXED_HINT = 'OpenAlex indexes no PMCIDs, so a PMCID resolves nothing however it is written. Convert it to a PMID or DOI — the NCBI ID Converter (https://www.ncbi.nlm.nih.gov/pmc/tools/idconv/) does this — and retry with that identifier.';
95
+ /**
96
+ * A keyword's own OpenAlex URL. Keywords are the only `ENTITY_TYPES` member OpenAlex addresses
97
+ * by slug rather than a native letter-and-number ID, and their URL spells out the `keywords/`
98
+ * path segment the endpoint already supplies — so stripping the host alone builds
99
+ * `/keywords/keywords/<slug>`, which 404s, and the ID a search returns cannot be fed back into
100
+ * its own lookup. The host is anchored on both ends so a look-alike domain is never read as
101
+ * OpenAlex, and the slug group requires at least one character so a slugless path stays unrouted.
102
+ */
103
+ const OPENALEX_KEYWORD_URL_PATTERN = /^https:\/\/openalex\.org\/keywords\/([^/?#]+)\/?$/i;
90
104
  /**
91
105
  * Prefix `normalizeId()` emits → the single entity type that identifier scheme addresses.
92
106
  * Every external scheme OpenAlex indexes belongs to exactly one entity type, which is what
@@ -113,10 +127,17 @@ const ENTITY_TYPE_BY_ID_PREFIX = {
113
127
  * "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/" → "pmcid:PMC1234567"
114
128
  * "https://pubmed.ncbi.nlm.nih.gov/21491125" → "pmid:21491125"
115
129
  * "PMID:21491125" → "pmid:21491125"
130
+ * "https://openalex.org/keywords/groundwater" → "groundwater"
116
131
  * "W2741809807" → "W2741809807"
117
132
  */
118
133
  export function normalizeId(id) {
119
134
  const trimmed = id.trim();
135
+ // Keyword URL → its bare slug. Runs ahead of the generic OpenAlex strip, which would leave
136
+ // the `keywords/` segment in place and double it against the endpoint path.
137
+ const keywordSlug = OPENALEX_KEYWORD_URL_PATTERN.exec(trimmed)?.[1];
138
+ if (keywordSlug) {
139
+ return keywordSlug;
140
+ }
120
141
  // Full OpenAlex URL
121
142
  if (trimmed.startsWith('https://openalex.org/')) {
122
143
  return trimmed.replace('https://openalex.org/', '');
@@ -187,19 +208,20 @@ const ENTITY_TYPE_BY_ID_LETTER = {
187
208
  A: 'authors',
188
209
  F: 'funders',
189
210
  I: 'institutions',
190
- K: 'keywords',
191
211
  P: 'publishers',
192
212
  S: 'sources',
193
213
  T: 'topics',
194
214
  W: 'works',
195
215
  };
196
216
  /**
197
- * Native OpenAlex IDs this server can route. `C` (the deprecated Concepts entity) and `G` are
198
- * deliberately absent even though `OPENALEX_ID_VALUE_PATTERN` accepts them as *filter values*:
199
- * neither is in `ENTITY_TYPES`, so there is no endpoint to fetch one from. They fall through to
200
- * autocomplete rather than being routed to a path that would 404.
217
+ * Native OpenAlex IDs this server can route. Three letters `OPENALEX_ID_VALUE_PATTERN` accepts
218
+ * as *filter values* are deliberately absent here. `C` (the deprecated Concepts entity) and `G`
219
+ * are not in `ENTITY_TYPES`, so there is no endpoint to fetch one from. `K` is in `ENTITY_TYPES`,
220
+ * but OpenAlex identifies keywords by slug and emits no numeric K-prefixed IDs, so routing that
221
+ * shape addressed nothing — `OPENALEX_KEYWORD_URL_PATTERN` is the keyword identifier. All three
222
+ * fall through to autocomplete rather than being routed to a path that would 404.
201
223
  */
202
- const ROUTABLE_OPENALEX_ID = /^([AFIKPSTW])\d+$/;
224
+ const ROUTABLE_OPENALEX_ID = /^([AFIPSTW])\d+$/;
203
225
  /**
204
226
  * Classify a query as an identifier, or return `undefined` for a name.
205
227
  *
@@ -207,8 +229,17 @@ const ROUTABLE_OPENALEX_ID = /^([AFIKPSTW])\d+$/;
207
229
  * and stamps external ones with their scheme prefix — so detection reduces to reading back what
208
230
  * it produced. Names never survive that: a name with a colon in it yields a prefix that is in no
209
231
  * scheme table, and anything else fails the native-ID pattern.
232
+ *
233
+ * Keywords are the exception, and are read from the raw query instead: their IDs are slugs, so
234
+ * `normalizeId()` reduces the URL to a bare word that no longer names an entity type. The URL
235
+ * form is the only keyword spelling that identifies itself — a bare slug is indistinguishable
236
+ * from any other name, and autocomplete is the right answer for it.
210
237
  */
211
238
  export function inferIdentifier(query) {
239
+ const keywordSlug = OPENALEX_KEYWORD_URL_PATTERN.exec(query.trim())?.[1];
240
+ if (keywordSlug) {
241
+ return { entityType: 'keywords', id: keywordSlug, scheme: 'openalex' };
242
+ }
212
243
  const normalized = normalizeId(query);
213
244
  const colon = normalized.indexOf(':');
214
245
  if (colon > 0) {
@@ -236,80 +267,54 @@ function reconstructAbstract(invertedIndex) {
236
267
  words.sort((a, b) => a[0] - b[0]);
237
268
  return words.map(([, word]) => word).join(' ');
238
269
  }
239
- const NAMED_HTML_ENTITIES = {
240
- amp: '&',
241
- apos: "'",
242
- gt: '>',
243
- lt: '<',
244
- nbsp: ' ',
245
- quot: '"',
246
- };
247
- const MAX_UNICODE_CODE_POINT = 0x10ffff;
248
- function codePointToString(code, fallback) {
249
- return Number.isInteger(code) && code >= 0 && code <= MAX_UNICODE_CODE_POINT
250
- ? String.fromCodePoint(code)
251
- : fallback;
252
- }
253
- /**
254
- * Decode HTML entities that OpenAlex sometimes returns in `display_name` and similar fields
255
- * (e.g. `Nature Clinical Practice Gastroenterology &#38; Hepatology`). Handles numeric
256
- * (`&#38;`), hex (`&#x27E9;`), and the common named entities. The trailing semicolon is
257
- * optional — upstream sometimes drops it (`&#38 Hepatology`) and HTML5 parsers tolerate
258
- * this; matching strictly would leave malformed entities literal in the output. Unknown or
259
- * out-of-range entities pass through unchanged so we never silently corrupt data.
260
- */
261
- function decodeHtmlEntities(input) {
262
- if (!input.includes('&'))
263
- return input;
264
- return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (match, body) => {
265
- const lower = body.toLowerCase();
266
- if (lower.startsWith('#x')) {
267
- return codePointToString(Number.parseInt(lower.slice(2), 16), match);
268
- }
269
- if (lower.startsWith('#')) {
270
- return codePointToString(Number.parseInt(lower.slice(1), 10), match);
271
- }
272
- return NAMED_HTML_ENTITIES[lower] ?? match;
273
- });
274
- }
275
- /** Recursively decode HTML entities in every string leaf of a JSON value. */
276
- function deepDecodeHtmlEntities(value) {
277
- if (typeof value === 'string')
278
- return decodeHtmlEntities(value);
279
- if (Array.isArray(value))
280
- return value.map(deepDecodeHtmlEntities);
281
- if (isRecord(value)) {
282
- const next = {};
283
- for (const [k, v] of Object.entries(value)) {
284
- next[k] = deepDecodeHtmlEntities(v);
285
- }
286
- return next;
287
- }
288
- return value;
289
- }
290
270
  function isRecord(value) {
291
271
  return value !== null && typeof value === 'object' && !Array.isArray(value);
292
272
  }
293
273
  function hasAbstractInvertedIndex(record) {
294
274
  return isRecord(record.abstract_inverted_index);
295
275
  }
276
+ /**
277
+ * Normalize provider text on one record, once, at the service boundary — both response surfaces
278
+ * read the result, and nothing downstream normalizes again.
279
+ */
296
280
  function normalizeEntityRecord(record) {
297
281
  if (!hasAbstractInvertedIndex(record))
298
- return deepDecodeHtmlEntities(record);
282
+ return normalizeProviderValue(record);
299
283
  const { abstract_inverted_index, ...rest } = record;
300
- // Decode keys via the reconstructed abstract — `deepDecodeHtmlEntities` only walks
301
- // values, so entities living in inverted-index word keys (e.g. `"&amp;"`) would
302
- // otherwise survive into the plaintext output.
284
+ // Normalize the reconstructed abstract, not the index — `normalizeProviderValue` only walks
285
+ // values, so entities and markup living in inverted-index word keys (`"&amp;"`, `"<!--"`)
286
+ // would otherwise survive, and a tag split across words only exists once they are joined.
303
287
  return {
304
- ...deepDecodeHtmlEntities(rest),
305
- abstract: decodeHtmlEntities(reconstructAbstract(abstract_inverted_index)),
288
+ ...normalizeProviderValue(rest),
289
+ abstract: normalizeProviderText(reconstructAbstract(abstract_inverted_index)),
306
290
  };
307
291
  }
308
292
  function normalizeEntityRecords(results) {
309
293
  return results.map(normalizeEntityRecord);
310
294
  }
311
295
  function normalizeAutocompleteRecords(results) {
312
- return results.map((r) => deepDecodeHtmlEntities(r));
296
+ return results.map((r) => normalizeProviderValue(r));
297
+ }
298
+ /**
299
+ * Keys OpenAlex gives the missing-value bucket `:include_unknown` adds: `-111`/`-111.0` on
300
+ * numeric fields in count order, `unknown` on string fields and under key-ascending traversal,
301
+ * and `https://openalex.org/<type>/unknown` on ID fields.
302
+ */
303
+ function isUnknownGroupKey(key) {
304
+ return key === '-111' || key === '-111.0' || key === 'unknown' || key.endsWith('/unknown');
305
+ }
306
+ /**
307
+ * Normalize one aggregation group. `key` stays byte-identical to upstream — it is what a caller
308
+ * matches and filters on — while the label is provider text. The unknown bucket is flagged only
309
+ * when it was requested, since no other page can carry one.
310
+ */
311
+ function normalizeGroup(group, includeUnknown) {
312
+ return {
313
+ key: group.key,
314
+ key_display_name: normalizeProviderValue(group.key_display_name),
315
+ count: group.count,
316
+ ...(includeUnknown && isUnknownGroupKey(group.key) ? { is_unknown: true } : {}),
317
+ };
313
318
  }
314
319
  /** Entity type → the record field holding its canonical external identifier. */
315
320
  const EXTERNAL_ID_FIELD = {
@@ -472,19 +477,38 @@ const OPENALEX_ID_VALUE_PATTERN = /^(?:https:\/\/openalex\.org\/)?[WAISTKPFCG]\d
472
477
  function isOpenAlexFilterValue(value) {
473
478
  return value.split('|').every((seg) => OPENALEX_ID_VALUE_PATTERN.test(seg));
474
479
  }
480
+ /**
481
+ * Rewrite one caller-supplied filter key to the upstream OpenAlex name, before the
482
+ * value-dependent `id` rewrite. Exported so the citation-graph tool can check a caller's key
483
+ * against the ones `direction` reserves: checking the raw key alone let `cited_works` past the
484
+ * guard, and it then aliased onto `cites` and collided with the direction's own value.
485
+ *
486
+ * Misses fall through unchanged so upstream's 400 with the valid-field list still fires for
487
+ * typos no map can preempt.
488
+ */
489
+ export function translateFilterKey(entityType, key) {
490
+ return entityType === 'works' ? (FILTER_ALIASES_WORKS[key] ?? key) : key;
491
+ }
475
492
  /**
476
493
  * Rewrite caller-supplied filter keys to the upstream OpenAlex names. Works-only key map plus
477
494
  * a universal `id` → `openalex` rewrite when the value looks like an OpenAlex ID. Values pass
478
- * through unchanged. Misses fall through so upstream's 400 with the valid-field list still
479
- * fires for typos no map can preempt.
495
+ * through unchanged.
496
+ *
497
+ * Emits clauses rather than a record because an alias and its canonical name are two separate
498
+ * constraints that resolve to one key — `{year: "2020", publication_year: "2024"}` is a request
499
+ * for works in both years. A record kept only whichever came last, so JSON property order
500
+ * silently decided the result set. Both are sent, matching OpenAlex's own repeated-key AND.
501
+ * Clauses that resolve to an identical key *and* value are deduplicated: restating one
502
+ * constraint changes nothing upstream.
480
503
  */
481
504
  function translateFilters(entityType, filters) {
482
- const aliases = entityType === 'works' ? FILTER_ALIASES_WORKS : undefined;
483
- const out = {};
505
+ const out = [];
484
506
  for (const [rawKey, value] of Object.entries(filters)) {
485
- const aliased = aliases?.[rawKey] ?? rawKey;
507
+ const aliased = translateFilterKey(entityType, rawKey);
486
508
  const finalKey = aliased === 'id' && isOpenAlexFilterValue(value) ? 'openalex' : aliased;
487
- out[finalKey] = value;
509
+ if (out.some(([key, existing]) => key === finalKey && existing === value))
510
+ continue;
511
+ out.push([finalKey, value]);
488
512
  }
489
513
  return out;
490
514
  }
@@ -632,30 +656,62 @@ const REJECTED_FIELD_RE = /^([a-z0-9_.]+)\s+is not a valid/i;
632
656
  const SORT_REQUIRES_SEARCH_RE = /must include a search query/i;
633
657
  /**
634
658
  * OpenAlex 400 emitted when group_by targets a field it cannot aggregate — a raw date, a
635
- * float, or a `*.search` operator: "Cannot group by date, number, or search fields." Names
636
- * no rejected field; the caller needs a categorical/year field, not a corrected name.
659
+ * float, or a `*.search` operator ("Cannot group by date, number, or search fields."), a
660
+ * field it refuses by name ("Cannot group by display_name."), or one it has not built
661
+ * ("Group by referenced_works is not supported at this time."). Either way the field name is
662
+ * valid; the caller needs a groupable field, not a corrected name.
637
663
  */
638
- const UNGROUPABLE_GROUP_BY_RE = /cannot group by/i;
664
+ const UNGROUPABLE_GROUP_BY_RE = /cannot group by|\bgroup by \S+ is not supported\b/i;
639
665
  /**
640
666
  * OpenAlex 400 emitted when an entity-ID filter receives something that isn't an ID —
641
667
  * typically a name: "'Albert' is not a valid OpenAlex ID." (upstream splits the value on
642
668
  * whitespace before validating, so only the first token is named). The fix is to resolve
643
- * the name to an ID first, which no other 400 shape's recovery says.
669
+ * the name to an ID first, which no other 400 shape's recovery says. Capture group 1 is the
670
+ * quoted value, when present — `isGroupByIdRejection` reads it.
644
671
  */
645
- const INVALID_ID_VALUE_RE = /is not a valid OpenAlex ID/i;
672
+ const INVALID_ID_VALUE_RE = /(?:'([^']*)'\s+)?is not a valid OpenAlex ID/i;
673
+ /**
674
+ * The same invalid-ID 400 also answers a group_by upstream cannot aggregate: authors grouped
675
+ * by `concepts.id`, `concept.id`, or `x_concepts.id` gets "'41008148' is not a valid OpenAlex
676
+ * ID.", naming a concept ID from upstream's own aggregation. Upstream validates `filter` before
677
+ * `group_by`, so a quoted value the request's filter carries is the caller's bad ID; anything
678
+ * else on a group_by request — including a request with no filter at all — is the group_by.
679
+ */
680
+ function isGroupByIdRejection(quotedValue, requestParams) {
681
+ if (!requestParams.group_by)
682
+ return false;
683
+ const filter = requestParams.filter;
684
+ return !filter || (quotedValue !== undefined && !filter.includes(quotedValue));
685
+ }
686
+ /**
687
+ * OpenAlex 400 emitted when the search text exceeds the length it accepts: the body's `error`
688
+ * reads "Search query too long" and its `message` quotes both the submitted length and the
689
+ * ceiling — "Your search is too long (1890 characters; the limit is 1500)." The bound is
690
+ * upstream's to move (its docs still say 2,000 characters with truncation), so it is reported
691
+ * from the message rather than pinned as a local `.max()` that would drift.
692
+ */
693
+ const QUERY_TOO_LONG_RE = /\bsearch\b(?: query)?(?: is)? too long\b/i;
646
694
  /**
647
695
  * A single upstream HTTP 400 spans several distinct failure shapes, each needing a different
648
696
  * caller recovery. Pick the declared tool reason from the message shape so the per-tool
649
697
  * `recovery` hint (resolved via `ctx.recoveryFor`) matches the actual failure instead of
650
- * always claiming a rejected field name. Ordering: the ID-value check runs first because it
651
- * is the only shape naming a concrete upstream concept; the field-name check is anchored at
652
- * string start; the rest are keyword probes; anything unmatched falls through to a neutral
653
- * `_other` reason so no shape inherits the field-name recovery by default.
698
+ * always claiming a rejected field name. Ordering: the over-long-query and ID-value checks run
699
+ * first because they are the shapes naming a concrete upstream concept; the field-name check is
700
+ * anchored at string start; the rest are keyword probes; anything unmatched falls through to a
701
+ * neutral `_other` reason so no shape inherits the field-name recovery by default. The
702
+ * invalid-ID shape alone also reads the request's own `filter`/`group_by` params, because
703
+ * upstream sends it for two different mistakes.
654
704
  */
655
- function classifyInvalidParamsReason(rawMessage) {
705
+ function classifyInvalidParamsReason(rawMessage, requestParams) {
656
706
  if (rawMessage !== undefined) {
657
- if (INVALID_ID_VALUE_RE.test(rawMessage))
658
- return 'upstream_invalid_id_value';
707
+ if (QUERY_TOO_LONG_RE.test(rawMessage))
708
+ return 'query_too_long';
709
+ const invalidId = INVALID_ID_VALUE_RE.exec(rawMessage);
710
+ if (invalidId) {
711
+ return isGroupByIdRejection(invalidId[1], requestParams)
712
+ ? 'upstream_ungroupable_group_by'
713
+ : 'upstream_invalid_id_value';
714
+ }
659
715
  if (REJECTED_FIELD_RE.test(rawMessage))
660
716
  return 'upstream_invalid_params';
661
717
  if (SORT_REQUIRES_SEARCH_RE.test(rawMessage))
@@ -665,6 +721,25 @@ function classifyInvalidParamsReason(rawMessage) {
665
721
  }
666
722
  return 'upstream_invalid_params_other';
667
723
  }
724
+ /**
725
+ * Longest `q`, in Unicode code points, that `/autocomplete/{entity_type}` answers. Past it, a
726
+ * query holding an ASCII letter gets an HTML 500 on every attempt, on all eight typed endpoints;
727
+ * cross-entity `/autocomplete` has no such bound. The bound is upstream's to move, so it only
728
+ * relabels a 500 that already happened rather than rejecting input: if OpenAlex raises it, the
729
+ * branch never fires; if it lowers it, the shorter failures stay `upstream_unavailable`.
730
+ */
731
+ const AUTOCOMPLETE_QUERY_MAX_CODE_POINTS = 1000;
732
+ /**
733
+ * The code-point length of a typed autocomplete `q` past `AUTOCOMPLETE_QUERY_MAX_CODE_POINTS` —
734
+ * the one request shape whose HTTP 500 is a caller error rather than an outage — or undefined.
735
+ * Counted in code points, as upstream counts: `length` would count an emoji twice.
736
+ */
737
+ function overLongAutocompleteQuery(path, params) {
738
+ if (!path.startsWith('/autocomplete/') || params.q === undefined)
739
+ return;
740
+ const codePoints = [...params.q].length;
741
+ return codePoints > AUTOCOMPLETE_QUERY_MAX_CODE_POINTS ? codePoints : undefined;
742
+ }
668
743
  /** Reason for a 429 caused by daily-budget exhaustion rather than burst throttling. */
669
744
  const BUDGET_EXHAUSTED_REASON = 'upstream_budget_exhausted';
670
745
  /**
@@ -689,11 +764,13 @@ function classifyRateLimitReason(rawMessage) {
689
764
  * failure shapes under one error code — 400 (rejected field / relevance sort without a
690
765
  * search / ungroupable group_by / non-ID filter value) and 429 (burst throttle vs. spent
691
766
  * daily budget) — and each shape needs its own caller recovery, so both discriminate on the
692
- * upstream message. Every other code maps to a single reason.
767
+ * upstream message, and the 400 invalid-ID shape on the request params too. Every other code
768
+ * maps to a single reason.
693
769
  */
694
- function resolveThrowReason(code, mappedReason, rawMessage) {
695
- if (code === JsonRpcErrorCode.InvalidParams)
696
- return classifyInvalidParamsReason(rawMessage);
770
+ function resolveThrowReason(code, mappedReason, rawMessage, requestParams) {
771
+ if (code === JsonRpcErrorCode.InvalidParams) {
772
+ return classifyInvalidParamsReason(rawMessage, requestParams);
773
+ }
697
774
  if (code === JsonRpcErrorCode.RateLimited)
698
775
  return classifyRateLimitReason(rawMessage);
699
776
  return mappedReason;
@@ -846,7 +923,7 @@ class OpenAlexService {
846
923
  return parsed;
847
924
  }
848
925
  catch (error) {
849
- this.throwNormalizedRequestError(error, path, ctx);
926
+ this.throwNormalizedRequestError(error, path, params, ctx);
850
927
  }
851
928
  }, {
852
929
  operation,
@@ -856,11 +933,24 @@ class OpenAlexService {
856
933
  signal: ctx.signal,
857
934
  });
858
935
  }
859
- throwNormalizedRequestError(error, path, ctx) {
936
+ throwNormalizedRequestError(error, path, params, ctx) {
860
937
  if (!(error instanceof McpError)) {
861
938
  throw error;
862
939
  }
863
940
  const statusCode = typeof error.data?.statusCode === 'number' ? error.data.statusCode : undefined;
941
+ // A typed autocomplete `q` past the bound 500s on every attempt, with an HTML body that
942
+ // names nothing — read as ServiceUnavailable it would be retried and reported as an outage.
943
+ // `retryable: false` keeps it out of `withRetry`, and the recovery says to shorten the name.
944
+ const overLongQuery = statusCode === 500 ? overLongAutocompleteQuery(path, params) : undefined;
945
+ if (overLongQuery !== undefined) {
946
+ throw invalidParams(`OpenAlex autocomplete failed on a ${overLongQuery.toLocaleString('en-US')}-character query — ${path} accepts at most ${AUTOCOMPLETE_QUERY_MAX_CODE_POINTS.toLocaleString('en-US')} characters.`, {
947
+ ...error.data,
948
+ path,
949
+ reason: 'query_too_long',
950
+ ...ctx.recoveryFor('query_too_long'),
951
+ retryable: false,
952
+ }, { cause: error });
953
+ }
864
954
  // Status-mapped failures classify off the HTTP status. Everything else — the client-side
865
955
  // `REQUEST_TIMEOUT_MS` abort, DNS/connection failures, and the `parseResponse` throws
866
956
  // (empty body, HTML, invalid JSON) — never carries a status, so the error's own code is
@@ -881,7 +971,7 @@ class OpenAlexService {
881
971
  const { factory } = normalized;
882
972
  const upstream = parseOpenAlexErrorBody(error.data?.responseBody);
883
973
  const rawMessage = upstream?.message ?? upstream?.error;
884
- const reason = resolveThrowReason(code, normalized.reason, rawMessage);
974
+ const reason = resolveThrowReason(code, normalized.reason, rawMessage, params);
885
975
  // A truncated body's appended valid-fields list is misleading. For an invalid-field
886
976
  // 400, replace it with catalog-backed ranked suggestions; otherwise strip it. The two
887
977
  // are mutually exclusive — appendFieldSuggestions already drops the partial list, so it
@@ -993,10 +1083,18 @@ class OpenAlexService {
993
1083
  else {
994
1084
  queryParams.per_page = String(params.perPage ?? 25);
995
1085
  }
996
- // Semantic search doesn't support cursor pagination — use page/per_page only.
997
- // Sampling returns one page only — cursor is mutually exclusive (enforced at the tool
998
- // layer; the service never sends both).
999
- if (params.searchMode !== 'semantic' && params.sample === undefined) {
1086
+ // Semantic search doesn't support cursor pagination — OpenAlex answers `search.semantic`
1087
+ // plus a cursor with a 400 — so it walks its candidate set with `page`/`per_page` instead.
1088
+ // Sampling returns one page only, so cursor is mutually exclusive there too. Both
1089
+ // combinations are rejected at the tool layer; the service never sends them together.
1090
+ // The tool also rejects `sample` under semantic mode (OpenAlex ignores it there, and the
1091
+ // population request below would pair `search.semantic` with `cursor=*`) and `sample`
1092
+ // beside `sort` (OpenAlex refuses the pair), so neither reaches this method.
1093
+ if (params.searchMode === 'semantic') {
1094
+ if (params.page !== undefined)
1095
+ queryParams.page = String(params.page);
1096
+ }
1097
+ else if (params.sample === undefined) {
1000
1098
  queryParams.cursor = params.cursor ?? '*';
1001
1099
  }
1002
1100
  const searchRequest = this.request(`/${params.entityType}`, queryParams, ctx);
@@ -1049,14 +1147,26 @@ class OpenAlexService {
1049
1147
  else if (keyAscOrder) {
1050
1148
  queryParams.cursor = '*';
1051
1149
  }
1052
- const data = (await this.request(`/${params.entityType}`, queryParams, ctx));
1150
+ const path = `/${params.entityType}`;
1151
+ const data = (await this.request(path, queryParams, ctx));
1152
+ // No `group_by` key at all is OpenAlex's plain *list* shape — the aggregation never ran.
1153
+ // Defaulting it to `[]` reported a successful aggregation of nothing, which a caller reads
1154
+ // as "the filters matched no groups". A present-but-empty array is that honest answer and
1155
+ // passes through.
1156
+ if (data.group_by === undefined) {
1157
+ throw serviceUnavailable(`OpenAlex returned no group_by aggregation for ${path} (group_by=${params.groupBy})`, {
1158
+ path,
1159
+ reason: 'upstream_missing_group_by',
1160
+ ...ctx.recoveryFor('upstream_missing_group_by'),
1161
+ });
1162
+ }
1053
1163
  return {
1054
1164
  meta: {
1055
1165
  count: data.meta.count,
1056
- groups_count: data.meta.groups_count ?? data.group_by?.length ?? null,
1166
+ groups_count: data.meta.groups_count ?? data.group_by.length,
1057
1167
  next_cursor: data.meta.next_cursor ?? null,
1058
1168
  },
1059
- groups: deepDecodeHtmlEntities(data.group_by ?? []),
1169
+ groups: data.group_by.map((group) => normalizeGroup(group, params.includeUnknown === true)),
1060
1170
  };
1061
1171
  }
1062
1172
  /**
@@ -1096,7 +1206,7 @@ class OpenAlexService {
1096
1206
  q: params.query,
1097
1207
  };
1098
1208
  if (hasEntries(params.filters)) {
1099
- queryParams.filter = buildFilterString(params.filters, ctx);
1209
+ queryParams.filter = buildFilterString(Object.entries(params.filters), ctx);
1100
1210
  }
1101
1211
  const data = (await this.request(path, queryParams, ctx));
1102
1212
  // Cross-entity autocomplete can return types not in our enum (country, license, etc.).
@@ -1120,7 +1230,7 @@ export function getOpenAlexService() {
1120
1230
  * Return the typed field catalog keyed by entity type → context → field list.
1121
1231
  * Only `filter` and `select` arrays are stored. `group_by` resolves to the `filter` key here;
1122
1232
  * the describe-fields handler then prunes the fields group_by cannot target (raw dates,
1123
- * `*.search` operators, and `from_*`/`to_*` range modifiers).
1233
+ * `*.search` operators, `from_*`/`to_*` range modifiers, and a per-type set from a live sweep).
1124
1234
  */
1125
1235
  export function getFieldCatalog() {
1126
1236
  return fieldCatalog;