@cyanheads/openalex-mcp-server 0.7.16 → 0.9.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 +48 -33
- package/CLAUDE.md +48 -33
- package/Dockerfile +98 -33
- package/README.md +23 -16
- package/changelog/0.8.x/0.8.0.md +38 -0
- package/changelog/0.9.x/0.9.0.md +48 -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/prompts/definitions/research-landscape.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +28 -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 +57 -10
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +62 -5
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +100 -52
- 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 +34 -4
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +38 -2
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +176 -41
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +75 -4
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +169 -73
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/render-budget.d.ts +4 -2
- package/dist/mcp-server/tools/render-budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-budget.js +4 -2
- package/dist/mcp-server/tools/render-budget.js.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts +12 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +23 -8
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +160 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +489 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/services/openalex/budget.d.ts +3 -1
- package/dist/services/openalex/budget.d.ts.map +1 -1
- package/dist/services/openalex/budget.js +3 -1
- package/dist/services/openalex/budget.js.map +1 -1
- package/dist/services/openalex/field-catalog.json +50 -1
- package/dist/services/openalex/filter-values.d.ts +46 -0
- package/dist/services/openalex/filter-values.d.ts.map +1 -0
- package/dist/services/openalex/filter-values.js +294 -0
- package/dist/services/openalex/filter-values.js.map +1 -0
- package/dist/services/openalex/iso-codes.json +1565 -0
- package/dist/services/openalex/openalex-service.d.ts +181 -16
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +593 -141
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +13 -5
- package/dist/services/openalex/provider-text.d.ts.map +1 -1
- package/dist/services/openalex/provider-text.js +799 -51
- package/dist/services/openalex/provider-text.js.map +1 -1
- package/dist/services/openalex/types.d.ts +1 -1
- package/dist/services/openalex/types.js +1 -1
- package/package.json +16 -13
- package/server.json +10 -24
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* @module services/openalex/openalex-service
|
|
4
4
|
*/
|
|
5
5
|
import { conflict, forbidden, invalidParams, invalidRequest, JsonRpcErrorCode, McpError, notFound, rateLimited, serviceUnavailable, timeout, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
6
|
-
import { fetchWithTimeout, httpStatusToErrorCode, withRetry } from '@cyanheads/mcp-ts-core/utils';
|
|
6
|
+
import { defaultIsTransient, fetchWithTimeout, httpStatusToErrorCode, withRetry, } from '@cyanheads/mcp-ts-core/utils';
|
|
7
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 { quoteCallerText, quotedCommaValue, upstreamFilterValue } from './filter-values.js';
|
|
11
12
|
import { normalizeProviderText, normalizeProviderValue } from './provider-text.js';
|
|
12
13
|
import { DEFAULT_SELECT, ENTITY_TYPES, } from './types.js';
|
|
13
14
|
import { redactUrlsInMessage } from './url-redaction.js';
|
|
@@ -37,27 +38,35 @@ const BOOLEAN_GROUP_BY_FIELDS = new Set([
|
|
|
37
38
|
* canonical key. A record would hold only the last of them.
|
|
38
39
|
*
|
|
39
40
|
* Comma handling (commas collide with OpenAlex's filter clause separator):
|
|
40
|
-
* -
|
|
41
|
-
* (
|
|
41
|
+
* - Free-text search keys (`title.search`, `abstract.search.exact`, `semantic.search`, …) and
|
|
42
|
+
* keys matched as whole text (`raw_affiliation_strings`, `display_name`, `geo.city`, …): the
|
|
43
|
+
* value goes out double-quoted as `quotedCommaValue()` writes it, so the comma stays part of the
|
|
44
|
+
* phrase. The quoted value is sent as written, `+` included.
|
|
42
45
|
* - All other keys: throw a pre-flight validation error — OpenAlex OR-lists use `|`, not commas.
|
|
46
|
+
*
|
|
47
|
+
* Every other value goes out as `upstreamFilterValue()` writes it: country and `language` values
|
|
48
|
+
* converted to the codes OpenAlex stores (a country segment that cannot be a code throws
|
|
49
|
+
* `invalid_country_code`), a multi-word value on a key matched as whole text (`geo.city`,
|
|
50
|
+
* `awards.funder_display_name`, …) double-quoted so OpenAlex reads it as one phrase, and the AND
|
|
51
|
+
* operator — a `+`, or a space on a key where words mean AND — sent as the AND it spells. A value
|
|
52
|
+
* that puts that operator beside a `|` throws `mixed_and_or_in_filter_value`, since OpenAlex drops
|
|
53
|
+
* every `|` alternative that ANDs more than one value; a value sent double-quoted that carries a
|
|
54
|
+
* `"` of its own throws `quote_in_filter_value`. Every throw runs before any request.
|
|
55
|
+
* `autocomplete()` sends its filters without `translateFilters()`, so the conversion lives here
|
|
56
|
+
* rather than there.
|
|
57
|
+
*
|
|
58
|
+
* Exported for the timing suite, which runs it on caller-sized keys and values.
|
|
43
59
|
*/
|
|
44
|
-
function buildFilterString(filters
|
|
60
|
+
export function buildFilterString(filters) {
|
|
45
61
|
return filters
|
|
46
62
|
.map(([key, value]) => {
|
|
47
63
|
if (value.includes(',')) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
// Non-search key: comma is a caller error — OpenAlex uses | for OR, not comma.
|
|
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.`, {
|
|
55
|
-
...ctx.recoveryFor('comma_in_filter_value'),
|
|
56
|
-
reason: 'comma_in_filter_value',
|
|
57
|
-
filterKey: key,
|
|
58
|
-
});
|
|
64
|
+
const phrase = quotedCommaValue(key, value);
|
|
65
|
+
if (phrase !== undefined)
|
|
66
|
+
return `${key}:${phrase}`;
|
|
67
|
+
throw invalidParams(`Filter ${quoteCallerText(key)} value contains a comma, which OpenAlex reads as a filter separator. Use \`|\` for OR (e.g. \`2020|2021\`); a free-text search filter (such as \`title.search\` or \`abstract.search.exact\`) or a field matched as whole text (such as \`raw_affiliation_strings\` or \`display_name\`) takes a phrase that holds a comma.`, { reason: 'comma_in_filter_value', filterKey: key });
|
|
59
68
|
}
|
|
60
|
-
return `${key}:${value}`;
|
|
69
|
+
return `${key}:${upstreamFilterValue(key, value)}`;
|
|
61
70
|
})
|
|
62
71
|
.join(',');
|
|
63
72
|
}
|
|
@@ -93,27 +102,85 @@ const PMC_URL_PATTERN = /^https?:\/\/(?:www\.)?(?:pmc\.ncbi\.nlm\.nih\.gov|ncbi\
|
|
|
93
102
|
*/
|
|
94
103
|
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
104
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*/
|
|
103
|
-
const
|
|
104
|
-
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
* The start of an OpenAlex URL — `openalex.org`, `www.openalex.org`, or `api.openalex.org`, with
|
|
106
|
+
* an `http(s)://` scheme or none — gating the `URL` parse in `parseOpenAlexUrl()`. The lookahead
|
|
107
|
+
* anchors the host on the right, so a look-alike that only starts with it (`openalex.org.example`,
|
|
108
|
+
* `openalex.org:8443`) is never parsed, and the scheme anchors it on the left.
|
|
109
|
+
*/
|
|
110
|
+
const OPENALEX_URL_START = /^(?:https?:\/\/)?(?:www\.|api\.)?openalex\.org(?=[/?#]|$)/i;
|
|
111
|
+
/** A native OpenAlex ID — one letter, then digits (`W2741809807`, the legacy venue `V137773608`). */
|
|
112
|
+
const NATIVE_ID_PATTERN = /^[a-z]\d+$/i;
|
|
113
|
+
/**
|
|
114
|
+
* Read the one ID an OpenAlex URL carries — a record page (`https://openalex.org/works/W…`), an
|
|
115
|
+
* API URL (`https://api.openalex.org/works/W…`), the short form (`https://openalex.org/W…`), or
|
|
116
|
+
* a keyword's `…/keywords/<slug>` — over `http://`, `https://`, or no scheme, ignoring a trailing
|
|
117
|
+
* `/`, the query string, and the fragment. The path must be `/<ID>` or `/<entity type>/<ID>`, a
|
|
118
|
+
* slug under `/keywords/`; anything else returns undefined and is left as the caller wrote it.
|
|
119
|
+
*
|
|
120
|
+
* Keywords are the one entity type OpenAlex addresses by slug, so their URL is the only spelling
|
|
121
|
+
* that identifies a keyword — and the entity-type segment has to go either way, since the request
|
|
122
|
+
* path supplies it again. A native ID's letter is upper-cased: OpenAlex IDs are upper-case, and
|
|
123
|
+
* the URL already says the value is one. A value with whitespace inside is refused before the
|
|
124
|
+
* parse, which would otherwise drop tabs and line breaks and join the pieces into an ID.
|
|
125
|
+
*/
|
|
126
|
+
function parseOpenAlexUrl(value) {
|
|
127
|
+
if (!OPENALEX_URL_START.test(value) || /\s/.test(value))
|
|
128
|
+
return;
|
|
129
|
+
const { pathname } = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
|
|
130
|
+
const path = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
131
|
+
const [first = '', second, ...rest] = path.split('/').slice(1);
|
|
132
|
+
if (rest.length > 0)
|
|
133
|
+
return;
|
|
134
|
+
if (second === undefined) {
|
|
135
|
+
return NATIVE_ID_PATTERN.test(first) ? nativeId(first) : undefined;
|
|
136
|
+
}
|
|
137
|
+
const entityType = first.toLowerCase();
|
|
138
|
+
if (!isEntityType(entityType))
|
|
139
|
+
return;
|
|
140
|
+
if (entityType === 'keywords')
|
|
141
|
+
return second ? { id: second, kind: 'keyword' } : undefined;
|
|
142
|
+
return NATIVE_ID_PATTERN.test(second) ? nativeId(second) : undefined;
|
|
143
|
+
}
|
|
144
|
+
function nativeId(id) {
|
|
145
|
+
return { id: `${id.charAt(0).toUpperCase()}${id.slice(1)}`, kind: 'native' };
|
|
146
|
+
}
|
|
147
|
+
function isEntityType(value) {
|
|
148
|
+
return ENTITY_TYPES.includes(value);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Prefix `normalizeId()` emits → the entity types an identifier of that scheme can address, in
|
|
152
|
+
* the order `inferIdentifier()` looks them up. Most schemes belong to one type. A ROR names an
|
|
153
|
+
* organization, and OpenAlex keeps an organization's institution, funder, and publisher records
|
|
154
|
+
* apart under the same ROR — NSF's `021nxhr62` is all three — so a ROR can address any of them.
|
|
155
|
+
*/
|
|
156
|
+
const ENTITY_TYPES_BY_ID_PREFIX = {
|
|
157
|
+
doi: ['works'],
|
|
158
|
+
issn: ['sources'],
|
|
159
|
+
orcid: ['authors'],
|
|
160
|
+
pmcid: ['works'],
|
|
161
|
+
pmid: ['works'],
|
|
162
|
+
ror: ['institutions', 'funders', 'publishers'],
|
|
116
163
|
};
|
|
164
|
+
/**
|
|
165
|
+
* The Crossref Funder Registry's DOI prefix. Funders are keyed by these DOIs, which OpenAlex's
|
|
166
|
+
* `/funders/doi:` path does not resolve — only the `ids.doi` filter matches them, with the bare
|
|
167
|
+
* DOI. A few thousand works carry one too, by mis-attribution, so a funder DOI still reaches a
|
|
168
|
+
* work when the caller asks for works.
|
|
169
|
+
*/
|
|
170
|
+
const FUNDER_REGISTRY_DOI_PREFIX = '10.13039/';
|
|
171
|
+
/**
|
|
172
|
+
* A DOI the funder `ids.doi` filter can match as one literal value: a DOI shape with nothing in
|
|
173
|
+
* it that filter syntax reads as structure — `|` (OR), `+` (AND), `,` (clause separator), or
|
|
174
|
+
* whitespace. A leading `!`, `<`, or `>` fails the `10.` anchor.
|
|
175
|
+
*/
|
|
176
|
+
const FUNDER_DOI_FILTER_VALUE = /^10\.\d{4,}\/[^\s,|+]+$/;
|
|
177
|
+
/**
|
|
178
|
+
* The filter clause that matches a funder by its bare DOI, or undefined for a value that clause
|
|
179
|
+
* would read as more than one literal DOI — it could only match some other funder, or every one.
|
|
180
|
+
*/
|
|
181
|
+
export function funderDoiFilter(doi) {
|
|
182
|
+
return FUNDER_DOI_FILTER_VALUE.test(doi) ? `ids.doi:${doi}` : undefined;
|
|
183
|
+
}
|
|
117
184
|
/**
|
|
118
185
|
* Detect ID format and return the API path segment.
|
|
119
186
|
* "10.1038/nature12373" → "doi:10.1038/nature12373"
|
|
@@ -127,20 +194,16 @@ const ENTITY_TYPE_BY_ID_PREFIX = {
|
|
|
127
194
|
* "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/" → "pmcid:PMC1234567"
|
|
128
195
|
* "https://pubmed.ncbi.nlm.nih.gov/21491125" → "pmid:21491125"
|
|
129
196
|
* "PMID:21491125" → "pmid:21491125"
|
|
130
|
-
* "https://openalex.org/
|
|
197
|
+
* "https://openalex.org/works/W2741809807" → "W2741809807"
|
|
198
|
+
* "https://api.openalex.org/keywords/groundwater" → "groundwater"
|
|
131
199
|
* "W2741809807" → "W2741809807"
|
|
132
200
|
*/
|
|
133
201
|
export function normalizeId(id) {
|
|
134
202
|
const trimmed = id.trim();
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return keywordSlug;
|
|
140
|
-
}
|
|
141
|
-
// Full OpenAlex URL
|
|
142
|
-
if (trimmed.startsWith('https://openalex.org/')) {
|
|
143
|
-
return trimmed.replace('https://openalex.org/', '');
|
|
203
|
+
// OpenAlex URL → the bare ID or keyword slug it carries (see parseOpenAlexUrl)
|
|
204
|
+
const openAlexId = parseOpenAlexUrl(trimmed);
|
|
205
|
+
if (openAlexId) {
|
|
206
|
+
return openAlexId.id;
|
|
144
207
|
}
|
|
145
208
|
// DOI URL
|
|
146
209
|
if (trimmed.startsWith('https://doi.org/')) {
|
|
@@ -196,7 +259,7 @@ export function normalizeId(id) {
|
|
|
196
259
|
const colon = trimmed.indexOf(':');
|
|
197
260
|
if (colon > 0) {
|
|
198
261
|
const scheme = trimmed.slice(0, colon).toLowerCase();
|
|
199
|
-
if (Object.hasOwn(
|
|
262
|
+
if (Object.hasOwn(ENTITY_TYPES_BY_ID_PREFIX, scheme)) {
|
|
200
263
|
return `${scheme}${trimmed.slice(colon)}`;
|
|
201
264
|
}
|
|
202
265
|
}
|
|
@@ -218,10 +281,70 @@ const ENTITY_TYPE_BY_ID_LETTER = {
|
|
|
218
281
|
* as *filter values* are deliberately absent here. `C` (the deprecated Concepts entity) and `G`
|
|
219
282
|
* are not in `ENTITY_TYPES`, so there is no endpoint to fetch one from. `K` is in `ENTITY_TYPES`,
|
|
220
283
|
* but OpenAlex identifies keywords by slug and emits no numeric K-prefixed IDs, so routing that
|
|
221
|
-
* shape addressed nothing —
|
|
222
|
-
*
|
|
284
|
+
* shape addressed nothing — a keyword URL is the keyword identifier. All three fall through to
|
|
285
|
+
* autocomplete rather than being routed to a path that would 404.
|
|
223
286
|
*/
|
|
224
287
|
const ROUTABLE_OPENALEX_ID = /^([AFIPSTW])\d+$/;
|
|
288
|
+
/**
|
|
289
|
+
* A native ID whose letter names the entity type that holds it — every routable letter plus `C`,
|
|
290
|
+
* a concept ID, which names a type this server does not serve. Read case-insensitively: OpenAlex
|
|
291
|
+
* answers a lower-case ID as it does the upper-case one. `V` (a legacy venue ID that still
|
|
292
|
+
* resolves under `/sources/`), `K`, and `G` are absent, so `search()` checks none of them.
|
|
293
|
+
*/
|
|
294
|
+
const LETTERED_OPENALEX_ID = /^([ACFIPSTW])\d+$/i;
|
|
295
|
+
/**
|
|
296
|
+
* Refuse a native ID whose letter names a different entity type than the lookup's. No record of
|
|
297
|
+
* one type carries another type's letter and every cross-letter lookup 404s upstream, so the
|
|
298
|
+
* request could only fail as a generic not-found; this names the type the ID belongs to instead.
|
|
299
|
+
*
|
|
300
|
+
* Keywords are exempt: their IDs are slugs, and real ones are shaped like native IDs (`P53`,
|
|
301
|
+
* `F1`, `S1`, `A1`), so on a keyword lookup a letter names nothing.
|
|
302
|
+
*/
|
|
303
|
+
function assertIdMatchesEntityType(id, entityType) {
|
|
304
|
+
const letter = entityType === 'keywords' ? undefined : LETTERED_OPENALEX_ID.exec(id)?.[1];
|
|
305
|
+
if (letter === undefined)
|
|
306
|
+
return;
|
|
307
|
+
const idType = ENTITY_TYPE_BY_ID_LETTER[letter.toUpperCase()];
|
|
308
|
+
if (idType === entityType)
|
|
309
|
+
return;
|
|
310
|
+
const requested = `it identifies no record among ${entityType}`;
|
|
311
|
+
const shown = capIdEcho(id);
|
|
312
|
+
throw validationError(idType
|
|
313
|
+
? `${shown} is an OpenAlex ${idType.replace(/s$/, '')} ID, which entity_type "${idType}" looks up — ${requested}.`
|
|
314
|
+
: `${shown} is an OpenAlex concept ID — concepts are a deprecated OpenAlex entity type this server does not serve, so ${requested}.`, { reason: 'id_type_mismatch', id, entityType, ...(idType ? { idEntityType: idType } : {}) });
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Characters a URL parse reads as structure rather than as part of a by-ID path's ID: `?` and
|
|
318
|
+
* `#` end the path, `\` separates segments on an `https:` URL, and tab, line feed, and carriage
|
|
319
|
+
* return are dropped outright, joining what they separated.
|
|
320
|
+
*/
|
|
321
|
+
const ID_PATH_STRUCTURE = /[?#\\\t\n\r]/g;
|
|
322
|
+
/** A `.` or `..` path segment in any spelling a URL parse resolves — `%2e` is a dot. */
|
|
323
|
+
const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/i;
|
|
324
|
+
/** The most of a refused `id` an error message quotes; `data.id` keeps it whole. */
|
|
325
|
+
const ID_ECHO_MAX_CHARS = 200;
|
|
326
|
+
/** A refused `id` cut to `ID_ECHO_MAX_CHARS`, so a caller-sized one cannot bloat the message. */
|
|
327
|
+
function capIdEcho(id) {
|
|
328
|
+
return id.length > ID_ECHO_MAX_CHARS ? `${id.slice(0, ID_ECHO_MAX_CHARS)}…` : id;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The by-ID request path for `id`: `/<entityType>/<id>`, with every character a URL parse would
|
|
332
|
+
* read as structure percent-encoded so it stays part of the ID. OpenAlex decodes them back, so a
|
|
333
|
+
* DOI that holds a `#` or `?` reaches the record it names instead of a truncated one.
|
|
334
|
+
*
|
|
335
|
+
* A `.` or `..` segment, plain or percent-encoded, is refused before any request, since encoding
|
|
336
|
+
* cannot keep it inside the path. `/` is left as written, since DOIs carry it, and an encoded
|
|
337
|
+
* `%2F` is one segment to both the parse and OpenAlex. The refusal quotes `callerId`, the
|
|
338
|
+
* identifier as the caller wrote it, rather than the normalized `id` (`doi:…` for a DOI URL).
|
|
339
|
+
*/
|
|
340
|
+
export function byIdPath(entityType, id, callerId = id) {
|
|
341
|
+
const encoded = id.replace(ID_PATH_STRUCTURE, encodeURIComponent);
|
|
342
|
+
const dots = encoded.split('/').find((segment) => DOT_SEGMENT.test(segment));
|
|
343
|
+
if (dots !== undefined) {
|
|
344
|
+
throw validationError(`id ${JSON.stringify(capIdEcho(callerId))} holds a "${dots}" path segment, which a ${entityType} lookup by ID does not accept.`, { reason: 'id_dot_segment', id: callerId, entityType });
|
|
345
|
+
}
|
|
346
|
+
return `/${entityType}/${encoded}`;
|
|
347
|
+
}
|
|
225
348
|
/**
|
|
226
349
|
* Classify a query as an identifier, or return `undefined` for a name.
|
|
227
350
|
*
|
|
@@ -230,26 +353,47 @@ const ROUTABLE_OPENALEX_ID = /^([AFIPSTW])\d+$/;
|
|
|
230
353
|
* it produced. Names never survive that: a name with a colon in it yields a prefix that is in no
|
|
231
354
|
* scheme table, and anything else fails the native-ID pattern.
|
|
232
355
|
*
|
|
233
|
-
* Keywords are the exception, and are read from the
|
|
234
|
-
* `normalizeId()`
|
|
235
|
-
* form is the only keyword spelling that identifies itself — a bare slug is
|
|
236
|
-
* from any other name, and autocomplete is the right answer for it.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
356
|
+
* Keywords are the exception, and are read from the URL before it is reduced: their IDs are
|
|
357
|
+
* slugs, so `normalizeId()` turns the URL into a bare word that no longer names an entity type.
|
|
358
|
+
* The URL form is the only keyword spelling that identifies itself — a bare slug is
|
|
359
|
+
* indistinguishable from any other name, and autocomplete is the right answer for it.
|
|
360
|
+
*
|
|
361
|
+
* `requested` (the caller's `entity_type`) narrows an identifier only among the types it can
|
|
362
|
+
* address: a ROR to one of institutions, funders, and publishers, and a Funder Registry DOI to
|
|
363
|
+
* works. Any other type leaves the identifier's own types in place — except `keywords`, under
|
|
364
|
+
* which a bare native-ID shape is a name: keyword IDs are slugs, and `P53` is a keyword.
|
|
365
|
+
*/
|
|
366
|
+
export function inferIdentifier(query, requested) {
|
|
367
|
+
const trimmed = query.trim();
|
|
368
|
+
const fromUrl = parseOpenAlexUrl(trimmed);
|
|
369
|
+
if (fromUrl?.kind === 'keyword') {
|
|
370
|
+
return { entityTypes: ['keywords'], id: fromUrl.id, scheme: 'openalex' };
|
|
242
371
|
}
|
|
243
|
-
|
|
372
|
+
if (fromUrl)
|
|
373
|
+
return nativeIdentifier(fromUrl.id);
|
|
374
|
+
const normalized = normalizeId(trimmed);
|
|
244
375
|
const colon = normalized.indexOf(':');
|
|
245
376
|
if (colon > 0) {
|
|
246
377
|
const scheme = normalized.slice(0, colon).toLowerCase();
|
|
247
|
-
const
|
|
248
|
-
|
|
378
|
+
const types = ownEntry(ENTITY_TYPES_BY_ID_PREFIX, scheme);
|
|
379
|
+
if (!types)
|
|
380
|
+
return;
|
|
381
|
+
if (scheme === 'doi' && normalized.startsWith(FUNDER_REGISTRY_DOI_PREFIX, colon + 1)) {
|
|
382
|
+
return { entityTypes: [requested === 'works' ? 'works' : 'funders'], id: normalized, scheme };
|
|
383
|
+
}
|
|
384
|
+
const entityTypes = requested !== undefined && types.includes(requested) ? [requested] : types;
|
|
385
|
+
return { entityTypes, id: normalized, scheme };
|
|
249
386
|
}
|
|
250
|
-
|
|
387
|
+
if (requested === 'keywords')
|
|
388
|
+
return;
|
|
389
|
+
const native = nativeIdentifier(normalized);
|
|
390
|
+
return native && { ...native, mayBeName: true };
|
|
391
|
+
}
|
|
392
|
+
/** A routable native OpenAlex ID as the identifier it is, or undefined for any other value. */
|
|
393
|
+
function nativeIdentifier(id) {
|
|
394
|
+
const letter = ROUTABLE_OPENALEX_ID.exec(id)?.[1];
|
|
251
395
|
const entityType = letter ? ENTITY_TYPE_BY_ID_LETTER[letter] : undefined;
|
|
252
|
-
return entityType ? { entityType, id
|
|
396
|
+
return entityType ? { entityTypes: [entityType], id, scheme: 'openalex' } : undefined;
|
|
253
397
|
}
|
|
254
398
|
/**
|
|
255
399
|
* Reconstruct abstract text from OpenAlex's inverted index format.
|
|
@@ -289,8 +433,47 @@ function normalizeEntityRecord(record) {
|
|
|
289
433
|
abstract: normalizeProviderText(reconstructAbstract(abstract_inverted_index)),
|
|
290
434
|
};
|
|
291
435
|
}
|
|
292
|
-
|
|
293
|
-
|
|
436
|
+
/** A string field of `value`, or null when it is absent or not a string. */
|
|
437
|
+
function stringOrNull(value, key) {
|
|
438
|
+
const field = value[key];
|
|
439
|
+
return typeof field === 'string' ? field : null;
|
|
440
|
+
}
|
|
441
|
+
/** One `author_list` entry: the authorship's author as `{id, display_name, orcid}`. */
|
|
442
|
+
function authorListEntry(authorship) {
|
|
443
|
+
const author = isRecord(authorship) && isRecord(authorship.author) ? authorship.author : {};
|
|
444
|
+
return {
|
|
445
|
+
id: stringOrNull(author, 'id'),
|
|
446
|
+
display_name: stringOrNull(author, 'display_name'),
|
|
447
|
+
orcid: stringOrNull(author, 'orcid'),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function authorListShape(entityType, select) {
|
|
451
|
+
if (entityType !== 'works' || !select?.includes(AUTHOR_LIST_FIELD))
|
|
452
|
+
return;
|
|
453
|
+
return {
|
|
454
|
+
keepAuthorships: select.some((field) => field === '*' ||
|
|
455
|
+
(field !== AUTHOR_LIST_FIELD && translateSelectField('works', field) === 'authorships')),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Add `author_list` to a normalized works record, derived from its `authorships`, and drop
|
|
460
|
+
* `authorships` unless the caller selected it too. A record OpenAlex returned without
|
|
461
|
+
* `authorships` gets no `author_list` — there is nothing to derive it from.
|
|
462
|
+
*/
|
|
463
|
+
function withAuthorList(record, shape) {
|
|
464
|
+
const { authorships, ...rest } = record;
|
|
465
|
+
if (!Array.isArray(authorships))
|
|
466
|
+
return record;
|
|
467
|
+
return {
|
|
468
|
+
...(shape.keepAuthorships ? record : rest),
|
|
469
|
+
[AUTHOR_LIST_FIELD]: authorships.map(authorListEntry),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function normalizeEntityRecords(results, authorList) {
|
|
473
|
+
return results.map((record) => {
|
|
474
|
+
const normalized = normalizeEntityRecord(record);
|
|
475
|
+
return authorList ? withAuthorList(normalized, authorList) : normalized;
|
|
476
|
+
});
|
|
294
477
|
}
|
|
295
478
|
function normalizeAutocompleteRecords(results) {
|
|
296
479
|
return results.map((r) => normalizeProviderValue(r));
|
|
@@ -380,13 +563,22 @@ export function toAutocompleteRecord(entityType, record) {
|
|
|
380
563
|
* output validation downstream of a successful API call.
|
|
381
564
|
*/
|
|
382
565
|
const REQUIRED_SEARCH_FIELDS = ['id', 'display_name'];
|
|
566
|
+
/**
|
|
567
|
+
* The works `select` field the server derives rather than projects: each authorship's author as
|
|
568
|
+
* `{id, display_name, orcid}`, from the `authorships` it requests in its place. Not an upstream
|
|
569
|
+
* field, so it is never forwarded, and neither the default projection nor `*` includes it.
|
|
570
|
+
*/
|
|
571
|
+
export const AUTHOR_LIST_FIELD = 'author_list';
|
|
383
572
|
/**
|
|
384
573
|
* Works-only `select` field aliases. LLM callers reach for intuitive names that don't match
|
|
385
574
|
* OpenAlex's vocabulary; renaming on the way out keeps tool ergonomics aligned with caller
|
|
386
|
-
* priors.
|
|
575
|
+
* priors. Canonical names pass through unchanged. Two names arrive under another key:
|
|
576
|
+
* `abstract`, reconstructed from `abstract_inverted_index`, and `author_list`, reduced from
|
|
577
|
+
* `authorships` — every other alias is a pure key rename.
|
|
387
578
|
*/
|
|
388
579
|
const SELECT_ALIASES_WORKS = {
|
|
389
580
|
abstract: 'abstract_inverted_index',
|
|
581
|
+
[AUTHOR_LIST_FIELD]: 'authorships',
|
|
390
582
|
authors: 'authorships',
|
|
391
583
|
year: 'publication_year',
|
|
392
584
|
};
|
|
@@ -403,16 +595,26 @@ const SELECT_ALIASES_SUMMARY_STATS = {
|
|
|
403
595
|
i10_index: 'summary_stats',
|
|
404
596
|
};
|
|
405
597
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
598
|
+
* Sources-only `select` aliases: the `summary_stats` leaves, plus `publisher`. OpenAlex names a
|
|
599
|
+
* journal's publisher `host_organization_name` — for a repository it names the hosting
|
|
600
|
+
* institution — and token-overlap suggestions cannot reach it from `publisher`. Select only: the
|
|
601
|
+
* filter, sort, and group_by sides have no single target (`host_organization` and
|
|
602
|
+
* `host_organization_lineage` both read as "publisher"), so `publisher` there keeps its 400.
|
|
603
|
+
*/
|
|
604
|
+
const SELECT_ALIASES_SOURCES = {
|
|
605
|
+
...SELECT_ALIASES_SUMMARY_STATS,
|
|
606
|
+
publisher: 'host_organization_name',
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* Entity types besides sources that expose `summary_stats`, verified against
|
|
610
|
+
* `api.openalex.org`. `works`, `topics`, and `keywords` carry no such field and reject it in
|
|
611
|
+
* `select`, so they get no alias map — the upstream 400 is the correct answer there.
|
|
409
612
|
*/
|
|
410
613
|
const SUMMARY_STATS_ENTITY_TYPES = new Set([
|
|
411
614
|
'authors',
|
|
412
615
|
'funders',
|
|
413
616
|
'institutions',
|
|
414
617
|
'publishers',
|
|
415
|
-
'sources',
|
|
416
618
|
]);
|
|
417
619
|
/**
|
|
418
620
|
* Works-only `filter` key aliases. Same shape as `SELECT_ALIASES_WORKS` — LLM callers reach
|
|
@@ -432,19 +634,45 @@ const FILTER_ALIASES_WORKS = {
|
|
|
432
634
|
* name. Keeps tool ergonomics symmetric with the response shape.
|
|
433
635
|
*
|
|
434
636
|
* Renames are fail-open: an unmapped name passes through and surfaces upstream's 400 with
|
|
435
|
-
* its valid-field list. Several
|
|
436
|
-
* aliasing rather than before.
|
|
637
|
+
* its valid-field list. Several names can share one upstream field (summary_stats leaves;
|
|
638
|
+
* `author_list` beside `authorships`), so the list is deduped after aliasing rather than before.
|
|
437
639
|
*/
|
|
438
640
|
function translateSelect(entityType, fields) {
|
|
439
|
-
const
|
|
641
|
+
const upstream = [...REQUIRED_SEARCH_FIELDS, ...fields].map((field) => translateSelectField(entityType, field));
|
|
642
|
+
return Array.from(new Set(upstream));
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* The upstream field one caller-supplied `select` name projects to — `authors` and
|
|
646
|
+
* `author_list` → `authorships` on works, `publisher` → `host_organization_name` on sources,
|
|
647
|
+
* `h_index` → `summary_stats` where that object exists; any other name unchanged.
|
|
648
|
+
*/
|
|
649
|
+
function translateSelectField(entityType, field) {
|
|
440
650
|
const aliases = entityType === 'works'
|
|
441
651
|
? SELECT_ALIASES_WORKS
|
|
442
|
-
:
|
|
443
|
-
?
|
|
444
|
-
:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return
|
|
652
|
+
: entityType === 'sources'
|
|
653
|
+
? SELECT_ALIASES_SOURCES
|
|
654
|
+
: SUMMARY_STATS_ENTITY_TYPES.has(entityType)
|
|
655
|
+
? SELECT_ALIASES_SUMMARY_STATS
|
|
656
|
+
: undefined;
|
|
657
|
+
return ownEntry(aliases, field) ?? field;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The record key one caller-supplied `select` name lands under: `author_list` keeps its own
|
|
661
|
+
* name; any other name lands under the upstream field it projects (`authors` → `authorships`).
|
|
662
|
+
* Exported so a tool can find the array a one-field projection returns.
|
|
663
|
+
*/
|
|
664
|
+
export function selectRecordKey(entityType, field) {
|
|
665
|
+
return entityType === 'works' && field === AUTHOR_LIST_FIELD
|
|
666
|
+
? field
|
|
667
|
+
: translateSelectField(entityType, field);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* A table's own entry for `key`, or undefined. The alias and scheme tables are plain object
|
|
671
|
+
* literals, so a bracket read of a caller-supplied `constructor` or `__proto__` returns the
|
|
672
|
+
* inherited `Object.prototype` member instead of missing.
|
|
673
|
+
*/
|
|
674
|
+
function ownEntry(table, key) {
|
|
675
|
+
return table !== undefined && Object.hasOwn(table, key) ? table[key] : undefined;
|
|
448
676
|
}
|
|
449
677
|
/**
|
|
450
678
|
* Resolve the upstream `select` projection for an entity request — applied identically to
|
|
@@ -463,19 +691,26 @@ function resolveSelect(entityType, select) {
|
|
|
463
691
|
return translateSelect(entityType, select?.length ? select : DEFAULT_SELECT[entityType]);
|
|
464
692
|
}
|
|
465
693
|
/**
|
|
466
|
-
*
|
|
467
|
-
* `
|
|
694
|
+
* A bare OpenAlex ID the `openalex` filter accepts as a value, in either case. Combined with
|
|
695
|
+
* `parseOpenAlexUrl()` for the URL spellings and with pipe-splitting in `isOpenAlexFilterValue`
|
|
696
|
+
* to detect OR-joined ID lists.
|
|
468
697
|
*/
|
|
469
|
-
const OPENALEX_ID_VALUE_PATTERN = /^
|
|
698
|
+
const OPENALEX_ID_VALUE_PATTERN = /^[WAISTKPFCG]\d+$/i;
|
|
470
699
|
/**
|
|
471
|
-
* Detect a value the `id:` filter would carry —
|
|
472
|
-
* pipe-joined for OR semantics. When matched,
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
700
|
+
* Detect a value the `id:` filter would carry — OpenAlex IDs, bare or in any URL spelling
|
|
701
|
+
* `parseOpenAlexUrl()` reads, optionally pipe-joined for OR semantics. When matched, `id` is
|
|
702
|
+
* rewritten to `openalex` (the canonical filter key) and the value is forwarded as written:
|
|
703
|
+
* upstream accepts every one of those spellings, and the space around a `|`. Stricter than any
|
|
704
|
+
* value so a future generic upstream `id:` filter wouldn't be shadowed — only values that read
|
|
705
|
+
* as OpenAlex IDs get redirected. Exported for the timing suite, which runs it on caller-sized
|
|
706
|
+
* values.
|
|
476
707
|
*/
|
|
477
|
-
function isOpenAlexFilterValue(value) {
|
|
478
|
-
return value.split('|').every((
|
|
708
|
+
export function isOpenAlexFilterValue(value) {
|
|
709
|
+
return value.split('|').every((segment) => {
|
|
710
|
+
const trimmed = segment.trim();
|
|
711
|
+
const fromUrl = parseOpenAlexUrl(trimmed);
|
|
712
|
+
return OPENALEX_ID_VALUE_PATTERN.test(fromUrl?.kind === 'native' ? fromUrl.id : trimmed);
|
|
713
|
+
});
|
|
479
714
|
}
|
|
480
715
|
/**
|
|
481
716
|
* Rewrite one caller-supplied filter key to the upstream OpenAlex name, before the
|
|
@@ -487,7 +722,7 @@ function isOpenAlexFilterValue(value) {
|
|
|
487
722
|
* typos no map can preempt.
|
|
488
723
|
*/
|
|
489
724
|
export function translateFilterKey(entityType, key) {
|
|
490
|
-
return entityType === 'works' ? (FILTER_ALIASES_WORKS
|
|
725
|
+
return entityType === 'works' ? (ownEntry(FILTER_ALIASES_WORKS, key) ?? key) : key;
|
|
491
726
|
}
|
|
492
727
|
/**
|
|
493
728
|
* Rewrite caller-supplied filter keys to the upstream OpenAlex names. Works-only key map plus
|
|
@@ -512,15 +747,34 @@ function translateFilters(entityType, filters) {
|
|
|
512
747
|
}
|
|
513
748
|
return out;
|
|
514
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* The `filter` parameter a search or group_by over `filters` sends: keys translated, then each
|
|
752
|
+
* clause built by `buildFilterString()`, whose pre-flight throws (`comma_in_filter_value`,
|
|
753
|
+
* `invalid_country_code`, `mixed_and_or_in_filter_value`, `quote_in_filter_value`) it shares.
|
|
754
|
+
* Exported so a tool that spends a request before its search — the citation graph's seed lookup —
|
|
755
|
+
* can run those checks first and fail without one.
|
|
756
|
+
*/
|
|
757
|
+
export function buildSearchFilter(entityType, filters) {
|
|
758
|
+
return buildFilterString(translateFilters(entityType, filters));
|
|
759
|
+
}
|
|
515
760
|
const MAX_ATTEMPTS = 3;
|
|
516
761
|
const BASE_BACKOFF_MS = 1000;
|
|
517
762
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
763
|
+
/**
|
|
764
|
+
* How long an identifier lookup and the name search it falls back to may take together, counted
|
|
765
|
+
* from the start of the lookup. One request's own worst case — three 10 s attempts and up to
|
|
766
|
+
* ~3.75 s of jittered backoff — is ~34 s, so a lookup that misses inside its retry budget still
|
|
767
|
+
* leaves the name search one full attempt, and the call settles by 45 s, inside the 60 s an MCP
|
|
768
|
+
* client waits. A lookup that ran past it (a long upstream `Retry-After`) leaves no time, and
|
|
769
|
+
* the name search reports a timeout without being sent.
|
|
770
|
+
*/
|
|
771
|
+
const NAME_FALLBACK_DEADLINE_MS = 45_000;
|
|
518
772
|
const KNOWN_AUTOCOMPLETE_TYPES = new Set(ENTITY_TYPES.map((type) => type.replace(/s$/, '')));
|
|
519
773
|
/**
|
|
520
774
|
* `JsonRpcErrorCode` → `{ factory, reason }` for shaping normalized OpenAlex throws.
|
|
521
775
|
* Driven by `httpStatusToErrorCode` so the status-table stays in lockstep with the
|
|
522
|
-
* framework. `reason` is the literal carried on `data.reason
|
|
523
|
-
* tool
|
|
776
|
+
* framework. `reason` is the literal carried on `data.reason`; the framework matches it
|
|
777
|
+
* against the calling tool's contract and fills that entry's recovery hint.
|
|
524
778
|
*/
|
|
525
779
|
const NORMALIZED_THROW_BY_CODE = {
|
|
526
780
|
[JsonRpcErrorCode.InvalidParams]: { factory: invalidParams, reason: 'upstream_invalid_params' },
|
|
@@ -694,7 +948,7 @@ const QUERY_TOO_LONG_RE = /\bsearch\b(?: query)?(?: is)? too long\b/i;
|
|
|
694
948
|
/**
|
|
695
949
|
* A single upstream HTTP 400 spans several distinct failure shapes, each needing a different
|
|
696
950
|
* caller recovery. Pick the declared tool reason from the message shape so the per-tool
|
|
697
|
-
* `recovery` hint (
|
|
951
|
+
* `recovery` hint (filled by the framework from the reason) matches the actual failure instead of
|
|
698
952
|
* always claiming a rejected field name. Ordering: the over-long-query and ID-value checks run
|
|
699
953
|
* first because they are the shapes naming a concrete upstream concept; the field-name check is
|
|
700
954
|
* anchored at string start; the rest are keyword probes; anything unmatched falls through to a
|
|
@@ -740,12 +994,77 @@ function overLongAutocompleteQuery(path, params) {
|
|
|
740
994
|
const codePoints = [...params.q].length;
|
|
741
995
|
return codePoints > AUTOCOMPLETE_QUERY_MAX_CODE_POINTS ? codePoints : undefined;
|
|
742
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* The start of the HTTP 400 `message` OpenAlex sends when a semantic search's query-embedding
|
|
999
|
+
* call fails for any reason but a timeout — `APIQueryParamsError(f"Failed to embed query: {e}")`
|
|
1000
|
+
* in ourresearch/openalex-elastic-api `core/semantic_search.py` (`embed_query`). The rest is the
|
|
1001
|
+
* embedding client's exception text: an HTTP error from the embedding service (its own 429 among
|
|
1002
|
+
* them, which is not this caller's rate), a connection failure, or a parse error — every one an
|
|
1003
|
+
* upstream fault, and one that names the embedding service's host.
|
|
1004
|
+
*/
|
|
1005
|
+
const SEMANTIC_EMBED_FAILURE_PREFIX = 'Failed to embed query:';
|
|
1006
|
+
/**
|
|
1007
|
+
* The HTTP 400 `message` OpenAlex sends when its embedding service is not configured (same module,
|
|
1008
|
+
* `get_embedding_client`) — an upstream fault worded as a parameter error.
|
|
1009
|
+
*/
|
|
1010
|
+
const SEMANTIC_NOT_CONFIGURED_MESSAGE = 'Semantic search is not configured';
|
|
1011
|
+
/**
|
|
1012
|
+
* Recovery for every `upstream_unavailable` failure of a semantic search — a relabelled embedding
|
|
1013
|
+
* failure, the 503 OpenAlex sends when the embedding timed out, or any other outage — since a
|
|
1014
|
+
* keyword search does not depend on the embedding service and can answer while it is down.
|
|
1015
|
+
*/
|
|
1016
|
+
export const SEMANTIC_UNAVAILABLE_HINT = 'OpenAlex semantic search is failing upstream. Retry in a minute, or rerun with search_mode "keyword", which does not depend on it.';
|
|
1017
|
+
/** Is this request a semantic search? `search.semantic` is the parameter only that mode sends. */
|
|
1018
|
+
function isSemanticSearch(params) {
|
|
1019
|
+
return params['search.semantic'] !== undefined;
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Did OpenAlex fail a semantic search's query embedding? Matched on the request mode and on the
|
|
1023
|
+
* whole `message` shape — anchored at its start — so no caller-error 400 (an unsupported semantic
|
|
1024
|
+
* filter, a missing query, two search methods at once) and no keyword-mode 400 ever matches.
|
|
1025
|
+
*/
|
|
1026
|
+
function isSemanticEmbedFailure(statusCode, rawMessage, params) {
|
|
1027
|
+
return (statusCode === 400 &&
|
|
1028
|
+
isSemanticSearch(params) &&
|
|
1029
|
+
rawMessage !== undefined &&
|
|
1030
|
+
(rawMessage.startsWith(SEMANTIC_EMBED_FAILURE_PREFIX) ||
|
|
1031
|
+
rawMessage === SEMANTIC_NOT_CONFIGURED_MESSAGE));
|
|
1032
|
+
}
|
|
743
1033
|
/** Reason for a 429 caused by daily-budget exhaustion rather than burst throttling. */
|
|
744
1034
|
const BUDGET_EXHAUSTED_REASON = 'upstream_budget_exhausted';
|
|
745
1035
|
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
* the
|
|
1036
|
+
* Recovery for a spent daily budget on a server that sends no API key. It runs on the smaller
|
|
1037
|
+
* anonymous budget, so a key is the one fix besides waiting. A keyed server's budget is already
|
|
1038
|
+
* the larger one, and a caller of a shared endpoint cannot set the server's environment, so that
|
|
1039
|
+
* case keeps the key-neutral recovery each tool's contract declares.
|
|
1040
|
+
*/
|
|
1041
|
+
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.";
|
|
1042
|
+
/**
|
|
1043
|
+
* The `error` field of the HTTP 429 OpenAlex answers a filter its paid plans gate with, observed
|
|
1044
|
+
* live (2026-10-18) for `created_date`, `updated_date`, and their `from_`/`to_` forms on works and
|
|
1045
|
+
* authors lists, a works group_by, and works autocomplete. Its `message` names the refused clause:
|
|
1046
|
+
* `The "from_created_date:2026-10-01" filter is part of daily sync, which comes with the Member+
|
|
1047
|
+
* and Partner plans. See https://help.openalex.org/access/sync/ for details.` It is a plan gate,
|
|
1048
|
+
* not a rate: no wait clears it.
|
|
1049
|
+
*/
|
|
1050
|
+
const PLAN_UPGRADE_REQUIRED_ERROR = 'Plan upgrade required';
|
|
1051
|
+
/** The filter name opening a plan-gated 429's `message`: `The "<filter>:<value>" filter …`. */
|
|
1052
|
+
const PLAN_GATED_FILTER_RE = /^The "([\w.]+):/;
|
|
1053
|
+
/**
|
|
1054
|
+
* The server's own message for a plan-gated filter, naming the filter when the upstream message
|
|
1055
|
+
* opens with it. The name is read as a field-name run only, so caller text the upstream clause
|
|
1056
|
+
* echoes cannot carry markdown or a line break into the message.
|
|
1057
|
+
*/
|
|
1058
|
+
export function planGatedFilterMessage(upstreamMessage) {
|
|
1059
|
+
const filter = upstreamMessage === undefined ? undefined : PLAN_GATED_FILTER_RE.exec(upstreamMessage)?.[1];
|
|
1060
|
+
const subject = filter === undefined ? 'A filter in this request' : `The \`${filter}\` filter`;
|
|
1061
|
+
return `${subject} needs a paid OpenAlex plan, which this server's OpenAlex access does not include.`;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Besides a filter its paid plans gate (classified before this, on its body's `error`), OpenAlex
|
|
1065
|
+
* returns 429 for two unrelated conditions: bursting past its per-second ceiling, and spending
|
|
1066
|
+
* the daily usage budget. The recoveries are opposites — one clears in seconds, the other not
|
|
1067
|
+
* until the midnight-UTC reset — so they need separate reasons.
|
|
749
1068
|
*
|
|
750
1069
|
* The literal budget-exhaustion body is not published in OpenAlex's docs, so the match is
|
|
751
1070
|
* deliberately loose: several independent signals, none of them load-bearing on an exact
|
|
@@ -827,11 +1146,7 @@ function appendFieldSuggestions(message, path) {
|
|
|
827
1146
|
const entityType = entityTypeFromPath(path);
|
|
828
1147
|
if (!entityType)
|
|
829
1148
|
return message;
|
|
830
|
-
const
|
|
831
|
-
const catalogEntry = fieldCatalog[entityType];
|
|
832
|
-
if (!catalogEntry)
|
|
833
|
-
return message;
|
|
834
|
-
const pool = catalogEntry[context];
|
|
1149
|
+
const pool = getFieldCatalog()[entityType][inferContext(message)];
|
|
835
1150
|
const suggestions = rankFields(rejectedField, pool, 3);
|
|
836
1151
|
if (suggestions.length === 0)
|
|
837
1152
|
return message;
|
|
@@ -885,8 +1200,12 @@ class OpenAlexService {
|
|
|
885
1200
|
this.apiKey = config.apiKey;
|
|
886
1201
|
this.mailto = config.mailto;
|
|
887
1202
|
}
|
|
888
|
-
/**
|
|
889
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* Execute an HTTP request against the OpenAlex API with retry on transient failures. With
|
|
1205
|
+
* `deadlineMs`, every attempt and backoff settles within that many milliseconds, after which
|
|
1206
|
+
* the request fails as a `Timeout`.
|
|
1207
|
+
*/
|
|
1208
|
+
request(path, params, ctx, deadlineMs) {
|
|
890
1209
|
const operation = `OpenAlex ${path}`;
|
|
891
1210
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
892
1211
|
// OpenAlex usage-based pricing (Feb 2026): the account credential authenticates as
|
|
@@ -923,7 +1242,7 @@ class OpenAlexService {
|
|
|
923
1242
|
return parsed;
|
|
924
1243
|
}
|
|
925
1244
|
catch (error) {
|
|
926
|
-
this.throwNormalizedRequestError(error, path, params
|
|
1245
|
+
this.throwNormalizedRequestError(error, path, params);
|
|
927
1246
|
}
|
|
928
1247
|
}, {
|
|
929
1248
|
operation,
|
|
@@ -931,24 +1250,52 @@ class OpenAlexService {
|
|
|
931
1250
|
baseDelayMs: BASE_BACKOFF_MS,
|
|
932
1251
|
maxRetries: MAX_ATTEMPTS - 1,
|
|
933
1252
|
signal: ctx.signal,
|
|
1253
|
+
...(deadlineMs !== undefined && { deadlineMs }),
|
|
934
1254
|
});
|
|
935
1255
|
}
|
|
936
|
-
throwNormalizedRequestError(error, path, params
|
|
1256
|
+
throwNormalizedRequestError(error, path, params) {
|
|
937
1257
|
if (!(error instanceof McpError)) {
|
|
938
1258
|
throw error;
|
|
939
1259
|
}
|
|
940
1260
|
const statusCode = typeof error.data?.statusCode === 'number' ? error.data.statusCode : undefined;
|
|
1261
|
+
const upstream = parseOpenAlexErrorBody(error.data?.responseBody);
|
|
1262
|
+
const rawMessage = upstream?.message ?? upstream?.error;
|
|
1263
|
+
const upstreamFields = {
|
|
1264
|
+
...(upstream?.error ? { upstreamError: upstream.error } : {}),
|
|
1265
|
+
...(upstream?.message ? { upstreamMessage: upstream.message } : {}),
|
|
1266
|
+
};
|
|
941
1267
|
// A typed autocomplete `q` past the bound 500s on every attempt, with an HTML body that
|
|
942
1268
|
// names nothing — read as ServiceUnavailable it would be retried and reported as an outage.
|
|
943
1269
|
// `retryable: false` keeps it out of `withRetry`, and the recovery says to shorten the name.
|
|
944
1270
|
const overLongQuery = statusCode === 500 ? overLongAutocompleteQuery(path, params) : undefined;
|
|
945
1271
|
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.`, {
|
|
1272
|
+
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 });
|
|
1273
|
+
}
|
|
1274
|
+
// A semantic search whose query embedding failed upstream comes back as a 400, but the request
|
|
1275
|
+
// was valid and the fault is transient. Thrown as the outage it is, it is retried like one, and
|
|
1276
|
+
// its message is the server's own: the upstream one names the embedding service's host. The
|
|
1277
|
+
// body stays verbatim on `data`, as on every upstream error.
|
|
1278
|
+
if (isSemanticEmbedFailure(statusCode, rawMessage, params)) {
|
|
1279
|
+
throw serviceUnavailable('OpenAlex could not embed the query for semantic search. The failure is upstream; the request itself is valid.', {
|
|
947
1280
|
...error.data,
|
|
948
1281
|
path,
|
|
949
|
-
reason: '
|
|
950
|
-
|
|
1282
|
+
reason: 'upstream_unavailable',
|
|
1283
|
+
recovery: { hint: SEMANTIC_UNAVAILABLE_HINT },
|
|
1284
|
+
...upstreamFields,
|
|
1285
|
+
}, { cause: error });
|
|
1286
|
+
}
|
|
1287
|
+
// A filter OpenAlex serves only on a paid plan comes back as a 429, which reads as a throttle:
|
|
1288
|
+
// it would be retried and reported with wait-and-retry advice, yet no wait clears a plan gate.
|
|
1289
|
+
// `Forbidden` sits outside `withRetry`'s transient set, so the first request is the only one,
|
|
1290
|
+
// and `retryable: false` tells the caller the same over the 429 status left on `data`. The
|
|
1291
|
+
// message is the server's own; the body stays verbatim on `data`, as on every upstream error.
|
|
1292
|
+
if (statusCode === 429 && upstream?.error === PLAN_UPGRADE_REQUIRED_ERROR) {
|
|
1293
|
+
throw forbidden(planGatedFilterMessage(upstream.message), {
|
|
1294
|
+
...error.data,
|
|
1295
|
+
path,
|
|
1296
|
+
reason: 'upstream_plan_required',
|
|
951
1297
|
retryable: false,
|
|
1298
|
+
...upstreamFields,
|
|
952
1299
|
}, { cause: error });
|
|
953
1300
|
}
|
|
954
1301
|
// Status-mapped failures classify off the HTTP status. Everything else — the client-side
|
|
@@ -969,8 +1316,6 @@ class OpenAlexService {
|
|
|
969
1316
|
});
|
|
970
1317
|
}
|
|
971
1318
|
const { factory } = normalized;
|
|
972
|
-
const upstream = parseOpenAlexErrorBody(error.data?.responseBody);
|
|
973
|
-
const rawMessage = upstream?.message ?? upstream?.error;
|
|
974
1319
|
const reason = resolveThrowReason(code, normalized.reason, rawMessage, params);
|
|
975
1320
|
// A truncated body's appended valid-fields list is misleading. For an invalid-field
|
|
976
1321
|
// 400, replace it with catalog-backed ranked suggestions; otherwise strip it. The two
|
|
@@ -995,10 +1340,10 @@ class OpenAlexService {
|
|
|
995
1340
|
...error.data,
|
|
996
1341
|
path,
|
|
997
1342
|
reason,
|
|
998
|
-
...ctx.recoveryFor(reason),
|
|
999
1343
|
// A PMCID lookup that 404s is not a miss to verify and retry — OpenAlex indexes no
|
|
1000
1344
|
// PMCIDs, so every tool's generic "verify the ID" recovery hands back the one instruction
|
|
1001
|
-
// that cannot help. Substituted here, where all three ID-accepting tools inherit it
|
|
1345
|
+
// that cannot help. Substituted here, where all three ID-accepting tools inherit it; an
|
|
1346
|
+
// explicit hint wins over the one the framework would fill from the contract.
|
|
1002
1347
|
...(code === JsonRpcErrorCode.NotFound && isPmcidLookup(path)
|
|
1003
1348
|
? { recovery: { hint: PMCID_NOT_INDEXED_HINT } }
|
|
1004
1349
|
: {}),
|
|
@@ -1007,8 +1352,17 @@ class OpenAlexService {
|
|
|
1007
1352
|
// flag is the framework's per-error opt-out from that loop; the contract's own
|
|
1008
1353
|
// `retryable` field only informs the client and cannot stop our own retries.
|
|
1009
1354
|
...(reason === BUDGET_EXHAUSTED_REASON ? { retryable: false } : {}),
|
|
1010
|
-
|
|
1011
|
-
|
|
1355
|
+
// Only a keyless server can raise its budget with a key; an explicit hint wins over the
|
|
1356
|
+
// key-neutral one the framework fills from the contract.
|
|
1357
|
+
...(reason === BUDGET_EXHAUSTED_REASON && !this.apiKey
|
|
1358
|
+
? { recovery: { hint: BUDGET_EXHAUSTED_KEYLESS_HINT } }
|
|
1359
|
+
: {}),
|
|
1360
|
+
// A keyword search can answer while semantic search is down, which the contract's generic
|
|
1361
|
+
// outage recovery cannot say; an explicit hint wins over it.
|
|
1362
|
+
...(reason === 'upstream_unavailable' && isSemanticSearch(params)
|
|
1363
|
+
? { recovery: { hint: SEMANTIC_UNAVAILABLE_HINT } }
|
|
1364
|
+
: {}),
|
|
1365
|
+
...upstreamFields,
|
|
1012
1366
|
};
|
|
1013
1367
|
throw factory(message, data, { cause: error });
|
|
1014
1368
|
}
|
|
@@ -1047,11 +1401,15 @@ class OpenAlexService {
|
|
|
1047
1401
|
// Singleton lookup by ID
|
|
1048
1402
|
if (params.id) {
|
|
1049
1403
|
const normalizedId = normalizeId(params.id);
|
|
1404
|
+
assertIdMatchesEntityType(normalizedId, params.entityType);
|
|
1050
1405
|
const select = resolveSelect(params.entityType, params.select);
|
|
1051
|
-
|
|
1406
|
+
if (params.entityType === 'funders' && normalizedId.startsWith('doi:')) {
|
|
1407
|
+
return this.funderByDoi(normalizedId.slice('doi:'.length), select, ctx);
|
|
1408
|
+
}
|
|
1409
|
+
const data = (await this.request(byIdPath(params.entityType, normalizedId, params.id), select ? { select: select.join(',') } : {}, ctx));
|
|
1052
1410
|
return {
|
|
1053
1411
|
meta: { count: 1, per_page: 1, next_cursor: null },
|
|
1054
|
-
results: normalizeEntityRecords([data]),
|
|
1412
|
+
results: normalizeEntityRecords([data], authorListShape(params.entityType, params.select)),
|
|
1055
1413
|
};
|
|
1056
1414
|
}
|
|
1057
1415
|
// Search/filter/list
|
|
@@ -1060,7 +1418,7 @@ class OpenAlexService {
|
|
|
1060
1418
|
queryParams[getSearchParamKey(params.searchMode)] = params.query;
|
|
1061
1419
|
}
|
|
1062
1420
|
if (hasEntries(params.filters)) {
|
|
1063
|
-
queryParams.filter =
|
|
1421
|
+
queryParams.filter = buildSearchFilter(params.entityType, params.filters);
|
|
1064
1422
|
}
|
|
1065
1423
|
const sort = normalizeSort(params.sort);
|
|
1066
1424
|
if (sort) {
|
|
@@ -1114,7 +1472,30 @@ class OpenAlexService {
|
|
|
1114
1472
|
...(populationData !== undefined && { count: populationData.meta.count }),
|
|
1115
1473
|
next_cursor: data.meta.next_cursor ?? null,
|
|
1116
1474
|
},
|
|
1117
|
-
results: normalizeEntityRecords(data.results),
|
|
1475
|
+
results: normalizeEntityRecords(data.results, authorListShape(params.entityType, params.select)),
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Look a funder up by its Crossref Funder Registry DOI. OpenAlex's `/funders/doi:` path
|
|
1480
|
+
* resolves nothing, so this is a one-result list request on the `ids.doi` filter, which OpenAlex
|
|
1481
|
+
* bills like any list request. A value the filter would read as more than one literal DOI fails
|
|
1482
|
+
* as not found without a request (see `funderDoiFilter`).
|
|
1483
|
+
*/
|
|
1484
|
+
async funderByDoi(doi, select, ctx) {
|
|
1485
|
+
const missing = () => notFound(`No funder in OpenAlex carries the DOI ${doi}.`, {
|
|
1486
|
+
reason: 'entity_not_found',
|
|
1487
|
+
path: '/funders',
|
|
1488
|
+
});
|
|
1489
|
+
const filter = funderDoiFilter(doi);
|
|
1490
|
+
if (!filter)
|
|
1491
|
+
throw missing();
|
|
1492
|
+
const data = (await this.request('/funders', { filter, per_page: '1', ...(select ? { select: select.join(',') } : {}) }, ctx));
|
|
1493
|
+
const [record] = data.results;
|
|
1494
|
+
if (!record)
|
|
1495
|
+
throw missing();
|
|
1496
|
+
return {
|
|
1497
|
+
meta: { count: 1, per_page: 1, next_cursor: null },
|
|
1498
|
+
results: normalizeEntityRecords([record]),
|
|
1118
1499
|
};
|
|
1119
1500
|
}
|
|
1120
1501
|
/** Group-by aggregation. */
|
|
@@ -1124,7 +1505,7 @@ class OpenAlexService {
|
|
|
1124
1505
|
? `${params.groupBy}:include_unknown`
|
|
1125
1506
|
: params.groupBy;
|
|
1126
1507
|
if (hasEntries(params.filters)) {
|
|
1127
|
-
queryParams.filter =
|
|
1508
|
+
queryParams.filter = buildSearchFilter(params.entityType, params.filters);
|
|
1128
1509
|
}
|
|
1129
1510
|
if (params.perPage !== undefined) {
|
|
1130
1511
|
queryParams.per_page = String(params.perPage);
|
|
@@ -1154,11 +1535,7 @@ class OpenAlexService {
|
|
|
1154
1535
|
// as "the filters matched no groups". A present-but-empty array is that honest answer and
|
|
1155
1536
|
// passes through.
|
|
1156
1537
|
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
|
-
});
|
|
1538
|
+
throw serviceUnavailable(`OpenAlex returned no group_by aggregation for ${path} (group_by=${params.groupBy})`, { path, reason: 'upstream_missing_group_by' });
|
|
1162
1539
|
}
|
|
1163
1540
|
return {
|
|
1164
1541
|
meta: {
|
|
@@ -1170,7 +1547,9 @@ class OpenAlexService {
|
|
|
1170
1547
|
};
|
|
1171
1548
|
}
|
|
1172
1549
|
/**
|
|
1173
|
-
* Resolve an identifier to the
|
|
1550
|
+
* Resolve an identifier to the entities it addresses, shaped as autocomplete matches — one
|
|
1551
|
+
* lookup per entity type in `identifier.entityTypes`, run in parallel, with the records found
|
|
1552
|
+
* returned in that order.
|
|
1174
1553
|
*
|
|
1175
1554
|
* Autocomplete matches on `display_name` and the external-ID URL, so whether an identifier
|
|
1176
1555
|
* resolves through it depends on scheme, bare-vs-URL form, and entity type — a bare ORCID or
|
|
@@ -1181,34 +1560,98 @@ class OpenAlexService {
|
|
|
1181
1560
|
* A miss returns no results rather than throwing. Autocomplete answers an unmatched query with
|
|
1182
1561
|
* an empty list, and routing identifiers through here is meant to make them behave *more* like
|
|
1183
1562
|
* the front door, not to hand one class of query a hard failure the rest never sees.
|
|
1563
|
+
*
|
|
1564
|
+
* A lookup that fails for any other reason drops only its own type, named in
|
|
1565
|
+
* `failedEntityTypes`, while another type returned a record. With no record to return, or once
|
|
1566
|
+
* the caller has cancelled, the failure is thrown instead — an empty or partial answer there
|
|
1567
|
+
* would claim records do not exist when the lookup for them never finished.
|
|
1568
|
+
*
|
|
1569
|
+
* `nameSearch` is the autocomplete to run when every lookup finds nothing — for a query that
|
|
1570
|
+
* may be a name rather than an ID (`ResolvedIdentifier.mayBeName`). It runs only on that miss,
|
|
1571
|
+
* so an ID that resolves still costs its one request, and only within what is left of
|
|
1572
|
+
* `NAME_FALLBACK_DEADLINE_MS` from the start of this call (see `nameSearchAfterMiss`).
|
|
1573
|
+
*
|
|
1574
|
+
* `query` is the text `identifier` was read from, as the caller wrote it. The lookups are sent
|
|
1575
|
+
* that spelling, which `search()` normalizes back to `identifier.id`, so a lookup that refuses
|
|
1576
|
+
* the identifier quotes what the caller sent.
|
|
1184
1577
|
*/
|
|
1185
|
-
async resolveIdentifier(identifier, ctx) {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
if (
|
|
1578
|
+
async resolveIdentifier(identifier, ctx, nameSearch, query = identifier.id) {
|
|
1579
|
+
const startedAt = Date.now();
|
|
1580
|
+
const settled = await Promise.allSettled(identifier.entityTypes.map((entityType) => this.search({ entityType, id: query }, ctx)));
|
|
1581
|
+
const results = [];
|
|
1582
|
+
const failed = [];
|
|
1583
|
+
settled.forEach((outcome, index) => {
|
|
1584
|
+
const entityType = identifier.entityTypes[index];
|
|
1585
|
+
if (outcome.status === 'fulfilled') {
|
|
1586
|
+
const [record] = outcome.value.results;
|
|
1587
|
+
if (record)
|
|
1588
|
+
results.push(toAutocompleteRecord(entityType, record));
|
|
1589
|
+
}
|
|
1590
|
+
else if (outcome.reason instanceof McpError &&
|
|
1591
|
+
outcome.reason.code === JsonRpcErrorCode.NotFound) {
|
|
1193
1592
|
ctx.log.debug('Identifier resolved to no entity', {
|
|
1194
1593
|
identifier: identifier.id,
|
|
1195
|
-
entityType
|
|
1594
|
+
entityType,
|
|
1196
1595
|
});
|
|
1197
|
-
return { results: [] };
|
|
1198
1596
|
}
|
|
1199
|
-
|
|
1597
|
+
else {
|
|
1598
|
+
failed.push({ entityType, error: outcome.reason });
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
const [firstFailure] = failed;
|
|
1602
|
+
if (firstFailure && (results.length === 0 || ctx.signal.aborted))
|
|
1603
|
+
throw firstFailure.error;
|
|
1604
|
+
if (results.length === 0 && nameSearch) {
|
|
1605
|
+
return this.nameSearchAfterMiss(nameSearch, NAME_FALLBACK_DEADLINE_MS - (Date.now() - startedAt), ctx);
|
|
1200
1606
|
}
|
|
1607
|
+
return {
|
|
1608
|
+
results,
|
|
1609
|
+
...(failed.length > 0 ? { failedEntityTypes: failed.map((f) => f.entityType) } : {}),
|
|
1610
|
+
};
|
|
1201
1611
|
}
|
|
1202
|
-
/**
|
|
1203
|
-
|
|
1612
|
+
/**
|
|
1613
|
+
* The name search an identifier lookup falls back to after finding nothing, bounded by the
|
|
1614
|
+
* `remainingMs` the lookup left of the call's deadline. It is a best-effort second reading of
|
|
1615
|
+
* the query, so a transient upstream failure — a timeout, the deadline running out, an outage,
|
|
1616
|
+
* a burst throttle — comes back as `nameFallback.failedReason` for the caller to see, beside
|
|
1617
|
+
* the lookup's own true miss. A caller cancellation, a request upstream rejected, a spent
|
|
1618
|
+
* budget, and anything unclassified are thrown: none of them is cured by calling again, and an
|
|
1619
|
+
* answer in their place would hide the error the caller needs to act on.
|
|
1620
|
+
*/
|
|
1621
|
+
async nameSearchAfterMiss(params, remainingMs, ctx) {
|
|
1622
|
+
if (remainingMs <= 0) {
|
|
1623
|
+
return { results: [], nameFallback: { failedReason: 'upstream_timeout' } };
|
|
1624
|
+
}
|
|
1625
|
+
try {
|
|
1626
|
+
const { results } = await this.autocomplete(params, ctx, remainingMs);
|
|
1627
|
+
return { results, nameFallback: {} };
|
|
1628
|
+
}
|
|
1629
|
+
catch (error) {
|
|
1630
|
+
const reason = error instanceof McpError && defaultIsTransient(error)
|
|
1631
|
+
? NORMALIZED_THROW_BY_CODE[error.code]?.reason
|
|
1632
|
+
: undefined;
|
|
1633
|
+
if (ctx.signal.aborted || reason === undefined)
|
|
1634
|
+
throw error;
|
|
1635
|
+
ctx.log.notice('Name search after an identifier miss failed upstream', {
|
|
1636
|
+
query: params.query,
|
|
1637
|
+
reason,
|
|
1638
|
+
});
|
|
1639
|
+
return { results: [], nameFallback: { failedReason: reason } };
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Autocomplete name resolution. `deadlineMs` bounds the whole request, retries included (see
|
|
1644
|
+
* `request`).
|
|
1645
|
+
*/
|
|
1646
|
+
async autocomplete(params, ctx, deadlineMs) {
|
|
1204
1647
|
const path = params.entityType ? `/autocomplete/${params.entityType}` : '/autocomplete';
|
|
1205
1648
|
const queryParams = {
|
|
1206
1649
|
q: params.query,
|
|
1207
1650
|
};
|
|
1208
1651
|
if (hasEntries(params.filters)) {
|
|
1209
|
-
queryParams.filter = buildFilterString(Object.entries(params.filters)
|
|
1652
|
+
queryParams.filter = buildFilterString(Object.entries(params.filters));
|
|
1210
1653
|
}
|
|
1211
|
-
const data = (await this.request(path, queryParams, ctx));
|
|
1654
|
+
const data = (await this.request(path, queryParams, ctx, deadlineMs));
|
|
1212
1655
|
// Cross-entity autocomplete can return types not in our enum (country, license, etc.).
|
|
1213
1656
|
// Filter to known entity types so callers can use results in other tools directly.
|
|
1214
1657
|
const results = params.entityType
|
|
@@ -1226,6 +1669,15 @@ export function getOpenAlexService() {
|
|
|
1226
1669
|
throw new Error('OpenAlexService not initialized — call initOpenAlexService() in setup()');
|
|
1227
1670
|
return _service;
|
|
1228
1671
|
}
|
|
1672
|
+
/**
|
|
1673
|
+
* The field catalog: OpenAlex's valid-field lists as `scripts/generate-field-catalog.ts` writes
|
|
1674
|
+
* them to `field-catalog.json`, plus the select fields this server derives itself
|
|
1675
|
+
* (`author_list` on works), which no upstream list names and the generator would drop.
|
|
1676
|
+
*/
|
|
1677
|
+
const FIELD_CATALOG = {
|
|
1678
|
+
...fieldCatalog,
|
|
1679
|
+
works: { ...fieldCatalog.works, select: [...fieldCatalog.works.select, AUTHOR_LIST_FIELD] },
|
|
1680
|
+
};
|
|
1229
1681
|
/**
|
|
1230
1682
|
* Return the typed field catalog keyed by entity type → context → field list.
|
|
1231
1683
|
* Only `filter` and `select` arrays are stored. `group_by` resolves to the `filter` key here;
|
|
@@ -1233,6 +1685,6 @@ export function getOpenAlexService() {
|
|
|
1233
1685
|
* `*.search` operators, `from_*`/`to_*` range modifiers, and a per-type set from a live sweep).
|
|
1234
1686
|
*/
|
|
1235
1687
|
export function getFieldCatalog() {
|
|
1236
|
-
return
|
|
1688
|
+
return FIELD_CATALOG;
|
|
1237
1689
|
}
|
|
1238
1690
|
//# sourceMappingURL=openalex-service.js.map
|