@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.
- package/AGENTS.md +8 -4
- package/CLAUDE.md +8 -4
- package/README.md +12 -9
- package/changelog/0.7.x/0.7.15.md +33 -0
- package/changelog/0.7.x/0.7.16.md +32 -0
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +22 -2
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +71 -17
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +12 -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 +56 -39
- 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 +18 -0
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +29 -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 +50 -0
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +176 -41
- 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 +2 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +24 -8
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/services/openalex/html-entities.json +2237 -0
- package/dist/services/openalex/openalex-service.d.ts +17 -1
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +211 -101
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +20 -0
- package/dist/services/openalex/provider-text.d.ts.map +1 -0
- package/dist/services/openalex/provider-text.js +276 -0
- package/dist/services/openalex/provider-text.js.map +1 -0
- package/dist/services/openalex/types.d.ts +16 -0
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +7 -0
- package/dist/services/openalex/types.js.map +1 -1
- package/package.json +6 -3
- 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,
|
|
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;
|
|
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
|
|
31
|
-
* Input:
|
|
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
|
|
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.
|
|
198
|
-
* deliberately absent
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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 = /^([
|
|
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 & Hepatology`). Handles numeric
|
|
256
|
-
* (`&`), hex (`⟩`), and the common named entities. The trailing semicolon is
|
|
257
|
-
* optional — upstream sometimes drops it (`& 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
|
|
282
|
+
return normalizeProviderValue(record);
|
|
299
283
|
const { abstract_inverted_index, ...rest } = record;
|
|
300
|
-
//
|
|
301
|
-
// values, so entities living in inverted-index word keys (
|
|
302
|
-
// otherwise survive
|
|
284
|
+
// Normalize the reconstructed abstract, not the index — `normalizeProviderValue` only walks
|
|
285
|
+
// values, so entities and markup living in inverted-index word keys (`"&"`, `"<!--"`)
|
|
286
|
+
// would otherwise survive, and a tag split across words only exists once they are joined.
|
|
303
287
|
return {
|
|
304
|
-
...
|
|
305
|
-
abstract:
|
|
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) =>
|
|
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.
|
|
479
|
-
*
|
|
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
|
|
483
|
-
const out = {};
|
|
505
|
+
const out = [];
|
|
484
506
|
for (const [rawKey, value] of Object.entries(filters)) {
|
|
485
|
-
const aliased =
|
|
507
|
+
const aliased = translateFilterKey(entityType, rawKey);
|
|
486
508
|
const finalKey = aliased === 'id' && isOpenAlexFilterValue(value) ? 'openalex' : aliased;
|
|
487
|
-
out[
|
|
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
|
|
636
|
-
*
|
|
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
|
|
651
|
-
*
|
|
652
|
-
* string start; the rest are keyword probes; anything unmatched falls through to a
|
|
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 (
|
|
658
|
-
return '
|
|
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
|
|
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 —
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
|
|
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
|
|
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
|
|
1166
|
+
groups_count: data.meta.groups_count ?? data.group_by.length,
|
|
1057
1167
|
next_cursor: data.meta.next_cursor ?? null,
|
|
1058
1168
|
},
|
|
1059
|
-
groups:
|
|
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,
|
|
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;
|