@cyanheads/openalex-mcp-server 0.7.15 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +32 -30
  2. package/CLAUDE.md +32 -30
  3. package/Dockerfile +88 -32
  4. package/README.md +12 -6
  5. package/changelog/0.7.x/0.7.16.md +32 -0
  6. package/changelog/0.8.x/0.8.0.md +38 -0
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  10. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +4 -3
  11. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +62 -19
  13. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +21 -1
  15. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +42 -42
  17. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/describe-fields.tool.js +82 -15
  20. package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +8 -1
  22. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +18 -7
  24. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +45 -1
  26. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/search-entities.tool.js +140 -64
  28. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/escape-markdown.d.ts +32 -0
  30. package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -0
  31. package/dist/mcp-server/tools/escape-markdown.js +389 -0
  32. package/dist/mcp-server/tools/escape-markdown.js.map +1 -0
  33. package/dist/mcp-server/tools/render-entity-record.d.ts +14 -2
  34. package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/render-entity-record.js +46 -15
  36. package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
  37. package/dist/mcp-server/tools/response-budget.d.ts +159 -0
  38. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
  39. package/dist/mcp-server/tools/response-budget.js +480 -0
  40. package/dist/mcp-server/tools/response-budget.js.map +1 -0
  41. package/dist/services/openalex/html-entities.json +2237 -0
  42. package/dist/services/openalex/openalex-service.d.ts +14 -1
  43. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  44. package/dist/services/openalex/openalex-service.js +147 -101
  45. package/dist/services/openalex/openalex-service.js.map +1 -1
  46. package/dist/services/openalex/provider-text.d.ts +27 -0
  47. package/dist/services/openalex/provider-text.d.ts.map +1 -0
  48. package/dist/services/openalex/provider-text.js +600 -0
  49. package/dist/services/openalex/provider-text.js.map +1 -0
  50. package/dist/services/openalex/types.d.ts +6 -1
  51. package/dist/services/openalex/types.d.ts.map +1 -1
  52. package/dist/services/openalex/types.js +1 -1
  53. package/package.json +11 -7
  54. package/server.json +3 -3
@@ -58,6 +58,12 @@ export declare function inferIdentifier(query: string): ResolvedIdentifier | und
58
58
  * a work has no works of its own, which is the same thing autocomplete reports.
59
59
  */
60
60
  export declare function toAutocompleteRecord(entityType: EntityType, record: EntityRecord): AutocompleteRecord;
61
+ /**
62
+ * The upstream field one caller-supplied `select` name projects to — `authors` → `authorships`
63
+ * on works, `h_index` → `summary_stats` where that object exists; any other name unchanged.
64
+ * Exported so a tool can find the record key a one-field projection lands under.
65
+ */
66
+ export declare function translateSelectField(entityType: SearchParams['entityType'], field: string): string;
61
67
  /**
62
68
  * Rewrite one caller-supplied filter key to the upstream OpenAlex name, before the
63
69
  * value-dependent `id` rewrite. Exported so the citation-graph tool can check a caller's key
@@ -68,6 +74,13 @@ export declare function toAutocompleteRecord(entityType: EntityType, record: Ent
68
74
  * typos no map can preempt.
69
75
  */
70
76
  export declare function translateFilterKey(entityType: SearchParams['entityType'], key: string): string;
77
+ /**
78
+ * Recovery for a spent daily budget on a server that sends no API key. It runs on the smaller
79
+ * anonymous budget, so a key is the one fix besides waiting. A keyed server's budget is already
80
+ * the larger one, and a caller of a shared endpoint cannot set the server's environment, so that
81
+ * case keeps the key-neutral recovery each tool's contract declares.
82
+ */
83
+ export declare const BUDGET_EXHAUSTED_KEYLESS_HINT = "This server's daily OpenAlex budget is spent and refills at midnight UTC \u2014 retrying before then will not succeed. The server sends no OpenAlex API key, so it runs on the smaller anonymous budget; setting OPENALEX_API_KEY to a free key (https://openalex.org/settings/api) raises it.";
71
84
  declare class OpenAlexService {
72
85
  private readonly baseUrl;
73
86
  private readonly apiKey;
@@ -104,7 +117,7 @@ export declare function getOpenAlexService(): OpenAlexService;
104
117
  * Return the typed field catalog keyed by entity type → context → field list.
105
118
  * Only `filter` and `select` arrays are stored. `group_by` resolves to the `filter` key here;
106
119
  * the describe-fields handler then prunes the fields group_by cannot target (raw dates,
107
- * `*.search` operators, and `from_*`/`to_*` range modifiers).
120
+ * `*.search` operators, `from_*`/`to_*` range modifiers, and a per-type set from a live sweep).
108
121
  */
109
122
  export declare function getFieldCatalog(): Record<EntityType, {
110
123
  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;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;AAuJD;;;;;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;AAiYD,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,CA6FtE;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"}
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;AAoFpB;;;;;;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;AA2ED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,YAAY,CAAC,YAAY,CAAC,EACtC,KAAK,EAAE,MAAM,GACZ,MAAM,CAQR;AA+CD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,YAAY,CAAC,YAAY,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE9F;AAqSD;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,mSACmP,CAAC;AAsJ9R,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;IAgGnC,OAAO,CAAC,aAAa;IAqCrB,sEAAsE;IAChE,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,CAAC,CA6FtE;IAED,4BAA4B;IACtB,OAAO,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CA2DzE;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
  /**
@@ -40,7 +41,7 @@ const BOOLEAN_GROUP_BY_FIELDS = new Set([
40
41
  * (skip if already quoted).
41
42
  * - All other keys: throw a pre-flight validation error — OpenAlex OR-lists use `|`, not commas.
42
43
  */
43
- function buildFilterString(filters, ctx) {
44
+ function buildFilterString(filters) {
44
45
  return filters
45
46
  .map(([key, value]) => {
46
47
  if (value.includes(',')) {
@@ -50,11 +51,7 @@ function buildFilterString(filters, ctx) {
50
51
  return `${key}:${quoted}`;
51
52
  }
52
53
  // Non-search key: comma is a caller error — OpenAlex uses | for OR, not comma.
53
- throw invalidParams(`Filter \`${key}\` value contains a comma, which OpenAlex reads as a filter separator. Use \`|\` for OR (e.g. \`2020|2021\`), or a \`.search\` filter or the \`query\` parameter for free text.`, {
54
- ...ctx.recoveryFor('comma_in_filter_value'),
55
- reason: 'comma_in_filter_value',
56
- filterKey: key,
57
- });
54
+ throw invalidParams(`Filter \`${key}\` value contains a comma, which OpenAlex reads as a filter separator. Use \`|\` for OR (e.g. \`2020|2021\`), or a \`.search\` filter or the \`query\` parameter for free text.`, { reason: 'comma_in_filter_value', filterKey: key });
58
55
  }
59
56
  return `${key}:${value}`;
60
57
  })
@@ -243,7 +240,7 @@ export function inferIdentifier(query) {
243
240
  const colon = normalized.indexOf(':');
244
241
  if (colon > 0) {
245
242
  const scheme = normalized.slice(0, colon).toLowerCase();
246
- const entityType = ENTITY_TYPE_BY_ID_PREFIX[scheme];
243
+ const entityType = ownEntry(ENTITY_TYPE_BY_ID_PREFIX, scheme);
247
244
  return entityType ? { entityType, id: normalized, scheme } : undefined;
248
245
  }
249
246
  const letter = ROUTABLE_OPENALEX_ID.exec(normalized)?.[1];
@@ -266,80 +263,54 @@ function reconstructAbstract(invertedIndex) {
266
263
  words.sort((a, b) => a[0] - b[0]);
267
264
  return words.map(([, word]) => word).join(' ');
268
265
  }
269
- const NAMED_HTML_ENTITIES = {
270
- amp: '&',
271
- apos: "'",
272
- gt: '>',
273
- lt: '<',
274
- nbsp: ' ',
275
- quot: '"',
276
- };
277
- const MAX_UNICODE_CODE_POINT = 0x10ffff;
278
- function codePointToString(code, fallback) {
279
- return Number.isInteger(code) && code >= 0 && code <= MAX_UNICODE_CODE_POINT
280
- ? String.fromCodePoint(code)
281
- : fallback;
282
- }
283
- /**
284
- * Decode HTML entities that OpenAlex sometimes returns in `display_name` and similar fields
285
- * (e.g. `Nature Clinical Practice Gastroenterology &#38; Hepatology`). Handles numeric
286
- * (`&#38;`), hex (`&#x27E9;`), and the common named entities. The trailing semicolon is
287
- * optional — upstream sometimes drops it (`&#38 Hepatology`) and HTML5 parsers tolerate
288
- * this; matching strictly would leave malformed entities literal in the output. Unknown or
289
- * out-of-range entities pass through unchanged so we never silently corrupt data.
290
- */
291
- function decodeHtmlEntities(input) {
292
- if (!input.includes('&'))
293
- return input;
294
- return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (match, body) => {
295
- const lower = body.toLowerCase();
296
- if (lower.startsWith('#x')) {
297
- return codePointToString(Number.parseInt(lower.slice(2), 16), match);
298
- }
299
- if (lower.startsWith('#')) {
300
- return codePointToString(Number.parseInt(lower.slice(1), 10), match);
301
- }
302
- return NAMED_HTML_ENTITIES[lower] ?? match;
303
- });
304
- }
305
- /** Recursively decode HTML entities in every string leaf of a JSON value. */
306
- function deepDecodeHtmlEntities(value) {
307
- if (typeof value === 'string')
308
- return decodeHtmlEntities(value);
309
- if (Array.isArray(value))
310
- return value.map(deepDecodeHtmlEntities);
311
- if (isRecord(value)) {
312
- const next = {};
313
- for (const [k, v] of Object.entries(value)) {
314
- next[k] = deepDecodeHtmlEntities(v);
315
- }
316
- return next;
317
- }
318
- return value;
319
- }
320
266
  function isRecord(value) {
321
267
  return value !== null && typeof value === 'object' && !Array.isArray(value);
322
268
  }
323
269
  function hasAbstractInvertedIndex(record) {
324
270
  return isRecord(record.abstract_inverted_index);
325
271
  }
272
+ /**
273
+ * Normalize provider text on one record, once, at the service boundary — both response surfaces
274
+ * read the result, and nothing downstream normalizes again.
275
+ */
326
276
  function normalizeEntityRecord(record) {
327
277
  if (!hasAbstractInvertedIndex(record))
328
- return deepDecodeHtmlEntities(record);
278
+ return normalizeProviderValue(record);
329
279
  const { abstract_inverted_index, ...rest } = record;
330
- // Decode keys via the reconstructed abstract — `deepDecodeHtmlEntities` only walks
331
- // values, so entities living in inverted-index word keys (e.g. `"&amp;"`) would
332
- // otherwise survive into the plaintext output.
280
+ // Normalize the reconstructed abstract, not the index — `normalizeProviderValue` only walks
281
+ // values, so entities and markup living in inverted-index word keys (`"&amp;"`, `"<!--"`)
282
+ // would otherwise survive, and a tag split across words only exists once they are joined.
333
283
  return {
334
- ...deepDecodeHtmlEntities(rest),
335
- abstract: decodeHtmlEntities(reconstructAbstract(abstract_inverted_index)),
284
+ ...normalizeProviderValue(rest),
285
+ abstract: normalizeProviderText(reconstructAbstract(abstract_inverted_index)),
336
286
  };
337
287
  }
338
288
  function normalizeEntityRecords(results) {
339
289
  return results.map(normalizeEntityRecord);
340
290
  }
341
291
  function normalizeAutocompleteRecords(results) {
342
- return results.map((r) => deepDecodeHtmlEntities(r));
292
+ return results.map((r) => normalizeProviderValue(r));
293
+ }
294
+ /**
295
+ * Keys OpenAlex gives the missing-value bucket `:include_unknown` adds: `-111`/`-111.0` on
296
+ * numeric fields in count order, `unknown` on string fields and under key-ascending traversal,
297
+ * and `https://openalex.org/<type>/unknown` on ID fields.
298
+ */
299
+ function isUnknownGroupKey(key) {
300
+ return key === '-111' || key === '-111.0' || key === 'unknown' || key.endsWith('/unknown');
301
+ }
302
+ /**
303
+ * Normalize one aggregation group. `key` stays byte-identical to upstream — it is what a caller
304
+ * matches and filters on — while the label is provider text. The unknown bucket is flagged only
305
+ * when it was requested, since no other page can carry one.
306
+ */
307
+ function normalizeGroup(group, includeUnknown) {
308
+ return {
309
+ key: group.key,
310
+ key_display_name: normalizeProviderValue(group.key_display_name),
311
+ count: group.count,
312
+ ...(includeUnknown && isUnknownGroupKey(group.key) ? { is_unknown: true } : {}),
313
+ };
343
314
  }
344
315
  /** Entity type → the record field holding its canonical external identifier. */
345
316
  const EXTERNAL_ID_FIELD = {
@@ -461,15 +432,29 @@ const FILTER_ALIASES_WORKS = {
461
432
  * aliasing rather than before.
462
433
  */
463
434
  function translateSelect(entityType, fields) {
464
- const withRequired = Array.from(new Set([...REQUIRED_SEARCH_FIELDS, ...fields]));
435
+ const upstream = [...REQUIRED_SEARCH_FIELDS, ...fields].map((field) => translateSelectField(entityType, field));
436
+ return Array.from(new Set(upstream));
437
+ }
438
+ /**
439
+ * The upstream field one caller-supplied `select` name projects to — `authors` → `authorships`
440
+ * on works, `h_index` → `summary_stats` where that object exists; any other name unchanged.
441
+ * Exported so a tool can find the record key a one-field projection lands under.
442
+ */
443
+ export function translateSelectField(entityType, field) {
465
444
  const aliases = entityType === 'works'
466
445
  ? SELECT_ALIASES_WORKS
467
446
  : SUMMARY_STATS_ENTITY_TYPES.has(entityType)
468
447
  ? SELECT_ALIASES_SUMMARY_STATS
469
448
  : undefined;
470
- if (!aliases)
471
- return withRequired;
472
- return Array.from(new Set(withRequired.map((field) => aliases[field] ?? field)));
449
+ return ownEntry(aliases, field) ?? field;
450
+ }
451
+ /**
452
+ * A table's own entry for `key`, or undefined. The alias and scheme tables are plain object
453
+ * literals, so a bracket read of a caller-supplied `constructor` or `__proto__` returns the
454
+ * inherited `Object.prototype` member instead of missing.
455
+ */
456
+ function ownEntry(table, key) {
457
+ return table !== undefined && Object.hasOwn(table, key) ? table[key] : undefined;
473
458
  }
474
459
  /**
475
460
  * Resolve the upstream `select` projection for an entity request — applied identically to
@@ -512,7 +497,7 @@ function isOpenAlexFilterValue(value) {
512
497
  * typos no map can preempt.
513
498
  */
514
499
  export function translateFilterKey(entityType, key) {
515
- return entityType === 'works' ? (FILTER_ALIASES_WORKS[key] ?? key) : key;
500
+ return entityType === 'works' ? (ownEntry(FILTER_ALIASES_WORKS, key) ?? key) : key;
516
501
  }
517
502
  /**
518
503
  * Rewrite caller-supplied filter keys to the upstream OpenAlex names. Works-only key map plus
@@ -544,8 +529,8 @@ const KNOWN_AUTOCOMPLETE_TYPES = new Set(ENTITY_TYPES.map((type) => type.replace
544
529
  /**
545
530
  * `JsonRpcErrorCode` → `{ factory, reason }` for shaping normalized OpenAlex throws.
546
531
  * Driven by `httpStatusToErrorCode` so the status-table stays in lockstep with the
547
- * framework. `reason` is the literal carried on `data.reason` and matched against
548
- * tool contracts via `ctx.recoveryFor`.
532
+ * framework. `reason` is the literal carried on `data.reason`; the framework matches it
533
+ * against the calling tool's contract and fills that entry's recovery hint.
549
534
  */
550
535
  const NORMALIZED_THROW_BY_CODE = {
551
536
  [JsonRpcErrorCode.InvalidParams]: { factory: invalidParams, reason: 'upstream_invalid_params' },
@@ -681,17 +666,33 @@ const REJECTED_FIELD_RE = /^([a-z0-9_.]+)\s+is not a valid/i;
681
666
  const SORT_REQUIRES_SEARCH_RE = /must include a search query/i;
682
667
  /**
683
668
  * OpenAlex 400 emitted when group_by targets a field it cannot aggregate — a raw date, a
684
- * float, or a `*.search` operator: "Cannot group by date, number, or search fields." Names
685
- * no rejected field; the caller needs a categorical/year field, not a corrected name.
669
+ * float, or a `*.search` operator ("Cannot group by date, number, or search fields."), a
670
+ * field it refuses by name ("Cannot group by display_name."), or one it has not built
671
+ * ("Group by referenced_works is not supported at this time."). Either way the field name is
672
+ * valid; the caller needs a groupable field, not a corrected name.
686
673
  */
687
- const UNGROUPABLE_GROUP_BY_RE = /cannot group by/i;
674
+ const UNGROUPABLE_GROUP_BY_RE = /cannot group by|\bgroup by \S+ is not supported\b/i;
688
675
  /**
689
676
  * OpenAlex 400 emitted when an entity-ID filter receives something that isn't an ID —
690
677
  * typically a name: "'Albert' is not a valid OpenAlex ID." (upstream splits the value on
691
678
  * whitespace before validating, so only the first token is named). The fix is to resolve
692
- * the name to an ID first, which no other 400 shape's recovery says.
679
+ * the name to an ID first, which no other 400 shape's recovery says. Capture group 1 is the
680
+ * quoted value, when present — `isGroupByIdRejection` reads it.
693
681
  */
694
- const INVALID_ID_VALUE_RE = /is not a valid OpenAlex ID/i;
682
+ const INVALID_ID_VALUE_RE = /(?:'([^']*)'\s+)?is not a valid OpenAlex ID/i;
683
+ /**
684
+ * The same invalid-ID 400 also answers a group_by upstream cannot aggregate: authors grouped
685
+ * by `concepts.id`, `concept.id`, or `x_concepts.id` gets "'41008148' is not a valid OpenAlex
686
+ * ID.", naming a concept ID from upstream's own aggregation. Upstream validates `filter` before
687
+ * `group_by`, so a quoted value the request's filter carries is the caller's bad ID; anything
688
+ * else on a group_by request — including a request with no filter at all — is the group_by.
689
+ */
690
+ function isGroupByIdRejection(quotedValue, requestParams) {
691
+ if (!requestParams.group_by)
692
+ return false;
693
+ const filter = requestParams.filter;
694
+ return !filter || (quotedValue !== undefined && !filter.includes(quotedValue));
695
+ }
695
696
  /**
696
697
  * OpenAlex 400 emitted when the search text exceeds the length it accepts: the body's `error`
697
698
  * reads "Search query too long" and its `message` quotes both the submitted length and the
@@ -703,18 +704,24 @@ const QUERY_TOO_LONG_RE = /\bsearch\b(?: query)?(?: is)? too long\b/i;
703
704
  /**
704
705
  * A single upstream HTTP 400 spans several distinct failure shapes, each needing a different
705
706
  * caller recovery. Pick the declared tool reason from the message shape so the per-tool
706
- * `recovery` hint (resolved via `ctx.recoveryFor`) matches the actual failure instead of
707
+ * `recovery` hint (filled by the framework from the reason) matches the actual failure instead of
707
708
  * always claiming a rejected field name. Ordering: the over-long-query and ID-value checks run
708
709
  * first because they are the shapes naming a concrete upstream concept; the field-name check is
709
710
  * anchored at string start; the rest are keyword probes; anything unmatched falls through to a
710
- * neutral `_other` reason so no shape inherits the field-name recovery by default.
711
+ * neutral `_other` reason so no shape inherits the field-name recovery by default. The
712
+ * invalid-ID shape alone also reads the request's own `filter`/`group_by` params, because
713
+ * upstream sends it for two different mistakes.
711
714
  */
712
- function classifyInvalidParamsReason(rawMessage) {
715
+ function classifyInvalidParamsReason(rawMessage, requestParams) {
713
716
  if (rawMessage !== undefined) {
714
717
  if (QUERY_TOO_LONG_RE.test(rawMessage))
715
718
  return 'query_too_long';
716
- if (INVALID_ID_VALUE_RE.test(rawMessage))
717
- return 'upstream_invalid_id_value';
719
+ const invalidId = INVALID_ID_VALUE_RE.exec(rawMessage);
720
+ if (invalidId) {
721
+ return isGroupByIdRejection(invalidId[1], requestParams)
722
+ ? 'upstream_ungroupable_group_by'
723
+ : 'upstream_invalid_id_value';
724
+ }
718
725
  if (REJECTED_FIELD_RE.test(rawMessage))
719
726
  return 'upstream_invalid_params';
720
727
  if (SORT_REQUIRES_SEARCH_RE.test(rawMessage))
@@ -724,8 +731,34 @@ function classifyInvalidParamsReason(rawMessage) {
724
731
  }
725
732
  return 'upstream_invalid_params_other';
726
733
  }
734
+ /**
735
+ * Longest `q`, in Unicode code points, that `/autocomplete/{entity_type}` answers. Past it, a
736
+ * query holding an ASCII letter gets an HTML 500 on every attempt, on all eight typed endpoints;
737
+ * cross-entity `/autocomplete` has no such bound. The bound is upstream's to move, so it only
738
+ * relabels a 500 that already happened rather than rejecting input: if OpenAlex raises it, the
739
+ * branch never fires; if it lowers it, the shorter failures stay `upstream_unavailable`.
740
+ */
741
+ const AUTOCOMPLETE_QUERY_MAX_CODE_POINTS = 1000;
742
+ /**
743
+ * The code-point length of a typed autocomplete `q` past `AUTOCOMPLETE_QUERY_MAX_CODE_POINTS` —
744
+ * the one request shape whose HTTP 500 is a caller error rather than an outage — or undefined.
745
+ * Counted in code points, as upstream counts: `length` would count an emoji twice.
746
+ */
747
+ function overLongAutocompleteQuery(path, params) {
748
+ if (!path.startsWith('/autocomplete/') || params.q === undefined)
749
+ return;
750
+ const codePoints = [...params.q].length;
751
+ return codePoints > AUTOCOMPLETE_QUERY_MAX_CODE_POINTS ? codePoints : undefined;
752
+ }
727
753
  /** Reason for a 429 caused by daily-budget exhaustion rather than burst throttling. */
728
754
  const BUDGET_EXHAUSTED_REASON = 'upstream_budget_exhausted';
755
+ /**
756
+ * Recovery for a spent daily budget on a server that sends no API key. It runs on the smaller
757
+ * anonymous budget, so a key is the one fix besides waiting. A keyed server's budget is already
758
+ * the larger one, and a caller of a shared endpoint cannot set the server's environment, so that
759
+ * case keeps the key-neutral recovery each tool's contract declares.
760
+ */
761
+ export const BUDGET_EXHAUSTED_KEYLESS_HINT = "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed. The server sends no OpenAlex API key, so it runs on the smaller anonymous budget; setting OPENALEX_API_KEY to a free key (https://openalex.org/settings/api) raises it.";
729
762
  /**
730
763
  * OpenAlex returns 429 for two unrelated conditions: bursting past its per-second ceiling,
731
764
  * and spending the daily usage budget. The recoveries are opposites — one clears in seconds,
@@ -748,11 +781,13 @@ function classifyRateLimitReason(rawMessage) {
748
781
  * failure shapes under one error code — 400 (rejected field / relevance sort without a
749
782
  * search / ungroupable group_by / non-ID filter value) and 429 (burst throttle vs. spent
750
783
  * daily budget) — and each shape needs its own caller recovery, so both discriminate on the
751
- * upstream message. Every other code maps to a single reason.
784
+ * upstream message, and the 400 invalid-ID shape on the request params too. Every other code
785
+ * maps to a single reason.
752
786
  */
753
- function resolveThrowReason(code, mappedReason, rawMessage) {
754
- if (code === JsonRpcErrorCode.InvalidParams)
755
- return classifyInvalidParamsReason(rawMessage);
787
+ function resolveThrowReason(code, mappedReason, rawMessage, requestParams) {
788
+ if (code === JsonRpcErrorCode.InvalidParams) {
789
+ return classifyInvalidParamsReason(rawMessage, requestParams);
790
+ }
756
791
  if (code === JsonRpcErrorCode.RateLimited)
757
792
  return classifyRateLimitReason(rawMessage);
758
793
  return mappedReason;
@@ -905,7 +940,7 @@ class OpenAlexService {
905
940
  return parsed;
906
941
  }
907
942
  catch (error) {
908
- this.throwNormalizedRequestError(error, path, ctx);
943
+ this.throwNormalizedRequestError(error, path, params);
909
944
  }
910
945
  }, {
911
946
  operation,
@@ -915,11 +950,18 @@ class OpenAlexService {
915
950
  signal: ctx.signal,
916
951
  });
917
952
  }
918
- throwNormalizedRequestError(error, path, ctx) {
953
+ throwNormalizedRequestError(error, path, params) {
919
954
  if (!(error instanceof McpError)) {
920
955
  throw error;
921
956
  }
922
957
  const statusCode = typeof error.data?.statusCode === 'number' ? error.data.statusCode : undefined;
958
+ // A typed autocomplete `q` past the bound 500s on every attempt, with an HTML body that
959
+ // names nothing — read as ServiceUnavailable it would be retried and reported as an outage.
960
+ // `retryable: false` keeps it out of `withRetry`, and the recovery says to shorten the name.
961
+ const overLongQuery = statusCode === 500 ? overLongAutocompleteQuery(path, params) : undefined;
962
+ if (overLongQuery !== undefined) {
963
+ 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.`, { ...error.data, path, reason: 'query_too_long', retryable: false }, { cause: error });
964
+ }
923
965
  // Status-mapped failures classify off the HTTP status. Everything else — the client-side
924
966
  // `REQUEST_TIMEOUT_MS` abort, DNS/connection failures, and the `parseResponse` throws
925
967
  // (empty body, HTML, invalid JSON) — never carries a status, so the error's own code is
@@ -940,7 +982,7 @@ class OpenAlexService {
940
982
  const { factory } = normalized;
941
983
  const upstream = parseOpenAlexErrorBody(error.data?.responseBody);
942
984
  const rawMessage = upstream?.message ?? upstream?.error;
943
- const reason = resolveThrowReason(code, normalized.reason, rawMessage);
985
+ const reason = resolveThrowReason(code, normalized.reason, rawMessage, params);
944
986
  // A truncated body's appended valid-fields list is misleading. For an invalid-field
945
987
  // 400, replace it with catalog-backed ranked suggestions; otherwise strip it. The two
946
988
  // are mutually exclusive — appendFieldSuggestions already drops the partial list, so it
@@ -964,10 +1006,10 @@ class OpenAlexService {
964
1006
  ...error.data,
965
1007
  path,
966
1008
  reason,
967
- ...ctx.recoveryFor(reason),
968
1009
  // A PMCID lookup that 404s is not a miss to verify and retry — OpenAlex indexes no
969
1010
  // PMCIDs, so every tool's generic "verify the ID" recovery hands back the one instruction
970
- // that cannot help. Substituted here, where all three ID-accepting tools inherit it.
1011
+ // that cannot help. Substituted here, where all three ID-accepting tools inherit it; an
1012
+ // explicit hint wins over the one the framework would fill from the contract.
971
1013
  ...(code === JsonRpcErrorCode.NotFound && isPmcidLookup(path)
972
1014
  ? { recovery: { hint: PMCID_NOT_INDEXED_HINT } }
973
1015
  : {}),
@@ -976,6 +1018,11 @@ class OpenAlexService {
976
1018
  // flag is the framework's per-error opt-out from that loop; the contract's own
977
1019
  // `retryable` field only informs the client and cannot stop our own retries.
978
1020
  ...(reason === BUDGET_EXHAUSTED_REASON ? { retryable: false } : {}),
1021
+ // Only a keyless server can raise its budget with a key; an explicit hint wins over the
1022
+ // key-neutral one the framework fills from the contract.
1023
+ ...(reason === BUDGET_EXHAUSTED_REASON && !this.apiKey
1024
+ ? { recovery: { hint: BUDGET_EXHAUSTED_KEYLESS_HINT } }
1025
+ : {}),
979
1026
  ...(upstream?.error ? { upstreamError: upstream.error } : {}),
980
1027
  ...(upstream?.message ? { upstreamMessage: upstream.message } : {}),
981
1028
  };
@@ -1029,7 +1076,7 @@ class OpenAlexService {
1029
1076
  queryParams[getSearchParamKey(params.searchMode)] = params.query;
1030
1077
  }
1031
1078
  if (hasEntries(params.filters)) {
1032
- queryParams.filter = buildFilterString(translateFilters(params.entityType, params.filters), ctx);
1079
+ queryParams.filter = buildFilterString(translateFilters(params.entityType, params.filters));
1033
1080
  }
1034
1081
  const sort = normalizeSort(params.sort);
1035
1082
  if (sort) {
@@ -1056,6 +1103,9 @@ class OpenAlexService {
1056
1103
  // plus a cursor with a 400 — so it walks its candidate set with `page`/`per_page` instead.
1057
1104
  // Sampling returns one page only, so cursor is mutually exclusive there too. Both
1058
1105
  // combinations are rejected at the tool layer; the service never sends them together.
1106
+ // The tool also rejects `sample` under semantic mode (OpenAlex ignores it there, and the
1107
+ // population request below would pair `search.semantic` with `cursor=*`) and `sample`
1108
+ // beside `sort` (OpenAlex refuses the pair), so neither reaches this method.
1059
1109
  if (params.searchMode === 'semantic') {
1060
1110
  if (params.page !== undefined)
1061
1111
  queryParams.page = String(params.page);
@@ -1090,7 +1140,7 @@ class OpenAlexService {
1090
1140
  ? `${params.groupBy}:include_unknown`
1091
1141
  : params.groupBy;
1092
1142
  if (hasEntries(params.filters)) {
1093
- queryParams.filter = buildFilterString(translateFilters(params.entityType, params.filters), ctx);
1143
+ queryParams.filter = buildFilterString(translateFilters(params.entityType, params.filters));
1094
1144
  }
1095
1145
  if (params.perPage !== undefined) {
1096
1146
  queryParams.per_page = String(params.perPage);
@@ -1120,11 +1170,7 @@ class OpenAlexService {
1120
1170
  // as "the filters matched no groups". A present-but-empty array is that honest answer and
1121
1171
  // passes through.
1122
1172
  if (data.group_by === undefined) {
1123
- throw serviceUnavailable(`OpenAlex returned no group_by aggregation for ${path} (group_by=${params.groupBy})`, {
1124
- path,
1125
- reason: 'upstream_missing_group_by',
1126
- ...ctx.recoveryFor('upstream_missing_group_by'),
1127
- });
1173
+ throw serviceUnavailable(`OpenAlex returned no group_by aggregation for ${path} (group_by=${params.groupBy})`, { path, reason: 'upstream_missing_group_by' });
1128
1174
  }
1129
1175
  return {
1130
1176
  meta: {
@@ -1132,7 +1178,7 @@ class OpenAlexService {
1132
1178
  groups_count: data.meta.groups_count ?? data.group_by.length,
1133
1179
  next_cursor: data.meta.next_cursor ?? null,
1134
1180
  },
1135
- groups: deepDecodeHtmlEntities(data.group_by),
1181
+ groups: data.group_by.map((group) => normalizeGroup(group, params.includeUnknown === true)),
1136
1182
  };
1137
1183
  }
1138
1184
  /**
@@ -1172,7 +1218,7 @@ class OpenAlexService {
1172
1218
  q: params.query,
1173
1219
  };
1174
1220
  if (hasEntries(params.filters)) {
1175
- queryParams.filter = buildFilterString(Object.entries(params.filters), ctx);
1221
+ queryParams.filter = buildFilterString(Object.entries(params.filters));
1176
1222
  }
1177
1223
  const data = (await this.request(path, queryParams, ctx));
1178
1224
  // Cross-entity autocomplete can return types not in our enum (country, license, etc.).
@@ -1196,7 +1242,7 @@ export function getOpenAlexService() {
1196
1242
  * Return the typed field catalog keyed by entity type → context → field list.
1197
1243
  * Only `filter` and `select` arrays are stored. `group_by` resolves to the `filter` key here;
1198
1244
  * the describe-fields handler then prunes the fields group_by cannot target (raw dates,
1199
- * `*.search` operators, and `from_*`/`to_*` range modifiers).
1245
+ * `*.search` operators, `from_*`/`to_*` range modifiers, and a per-type set from a live sweep).
1200
1246
  */
1201
1247
  export function getFieldCatalog() {
1202
1248
  return fieldCatalog;