@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.
- package/AGENTS.md +32 -30
- package/CLAUDE.md +32 -30
- package/Dockerfile +88 -32
- package/README.md +12 -6
- package/changelog/0.7.x/0.7.16.md +32 -0
- package/changelog/0.8.x/0.8.0.md +38 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +4 -3
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +62 -19
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +21 -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 +42 -42
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js +82 -15
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +8 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +18 -7
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +45 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +140 -64
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/escape-markdown.d.ts +32 -0
- package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -0
- package/dist/mcp-server/tools/escape-markdown.js +389 -0
- package/dist/mcp-server/tools/escape-markdown.js.map +1 -0
- package/dist/mcp-server/tools/render-entity-record.d.ts +14 -2
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +46 -15
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +159 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +480 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/services/openalex/html-entities.json +2237 -0
- package/dist/services/openalex/openalex-service.d.ts +14 -1
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +147 -101
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +27 -0
- package/dist/services/openalex/provider-text.d.ts.map +1 -0
- package/dist/services/openalex/provider-text.js +600 -0
- package/dist/services/openalex/provider-text.js.map +1 -0
- package/dist/services/openalex/types.d.ts +6 -1
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +1 -1
- package/package.json +11 -7
- 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,
|
|
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;
|
|
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
|
|
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
|
|
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 & Hepatology`). Handles numeric
|
|
286
|
-
* (`&`), hex (`⟩`), and the common named entities. The trailing semicolon is
|
|
287
|
-
* optional — upstream sometimes drops it (`& 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
|
|
278
|
+
return normalizeProviderValue(record);
|
|
329
279
|
const { abstract_inverted_index, ...rest } = record;
|
|
330
|
-
//
|
|
331
|
-
// values, so entities living in inverted-index word keys (
|
|
332
|
-
// otherwise survive
|
|
280
|
+
// Normalize the reconstructed abstract, not the index — `normalizeProviderValue` only walks
|
|
281
|
+
// values, so entities and markup living in inverted-index word keys (`"&"`, `"<!--"`)
|
|
282
|
+
// would otherwise survive, and a tag split across words only exists once they are joined.
|
|
333
283
|
return {
|
|
334
|
-
...
|
|
335
|
-
abstract:
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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
|
|
548
|
-
* tool
|
|
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
|
|
685
|
-
*
|
|
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 (
|
|
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
|
-
|
|
717
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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)
|
|
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)
|
|
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:
|
|
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)
|
|
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,
|
|
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;
|