@cyanheads/openalex-mcp-server 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +25 -7
  2. package/CLAUDE.md +25 -7
  3. package/Dockerfile +17 -8
  4. package/README.md +28 -19
  5. package/changelog/0.10.x/0.10.0.md +30 -0
  6. package/changelog/0.9.x/0.9.0.md +48 -0
  7. package/dist/config/server-config.d.ts +2 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +10 -0
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  14. package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
  15. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +35 -4
  16. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +86 -41
  18. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +48 -5
  20. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +77 -24
  22. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/describe-fields.tool.js +34 -4
  25. package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +43 -1
  27. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +182 -40
  29. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +64 -6
  31. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/search-entities.tool.js +138 -35
  33. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/page-size.d.ts +18 -0
  35. package/dist/mcp-server/tools/page-size.d.ts.map +1 -0
  36. package/dist/mcp-server/tools/page-size.js +28 -0
  37. package/dist/mcp-server/tools/page-size.js.map +1 -0
  38. package/dist/mcp-server/tools/render-budget.d.ts +4 -2
  39. package/dist/mcp-server/tools/render-budget.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/render-budget.js +4 -2
  41. package/dist/mcp-server/tools/render-budget.js.map +1 -1
  42. package/dist/mcp-server/tools/response-budget.d.ts +3 -3
  43. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/response-budget.js +64 -35
  45. package/dist/mcp-server/tools/response-budget.js.map +1 -1
  46. package/dist/services/openalex/budget.d.ts +3 -1
  47. package/dist/services/openalex/budget.d.ts.map +1 -1
  48. package/dist/services/openalex/budget.js +3 -1
  49. package/dist/services/openalex/budget.js.map +1 -1
  50. package/dist/services/openalex/field-catalog.json +50 -1
  51. package/dist/services/openalex/filter-values.d.ts +48 -0
  52. package/dist/services/openalex/filter-values.d.ts.map +1 -0
  53. package/dist/services/openalex/filter-values.js +350 -0
  54. package/dist/services/openalex/filter-values.js.map +1 -0
  55. package/dist/services/openalex/iso-codes.json +1565 -0
  56. package/dist/services/openalex/openalex-service.d.ts +176 -20
  57. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  58. package/dist/services/openalex/openalex-service.js +573 -127
  59. package/dist/services/openalex/openalex-service.js.map +1 -1
  60. package/dist/services/openalex/provider-text.d.ts +7 -6
  61. package/dist/services/openalex/provider-text.d.ts.map +1 -1
  62. package/dist/services/openalex/provider-text.js +485 -61
  63. package/dist/services/openalex/provider-text.js.map +1 -1
  64. package/dist/services/openalex/types.d.ts +5 -2
  65. package/dist/services/openalex/types.d.ts.map +1 -1
  66. package/package.json +14 -12
  67. package/server.json +35 -21
@@ -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';
@@ -27,6 +28,12 @@ const BOOLEAN_GROUP_BY_FIELDS = new Set([
27
28
  'apc_paid',
28
29
  'has_fulltext',
29
30
  ]);
31
+ /** Whether this aggregation keeps count order, ignoring the native unknown-bucket suffix. */
32
+ export function isBooleanGroupBy(groupBy) {
33
+ const suffix = ':include_unknown';
34
+ const baseField = groupBy.endsWith(suffix) ? groupBy.slice(0, -suffix.length) : groupBy;
35
+ return BOOLEAN_GROUP_BY_FIELDS.has(baseField);
36
+ }
30
37
  /**
31
38
  * Build an OpenAlex filter string from ordered key-value clauses.
32
39
  * Input: [["cited_by_count", ">100"], ["is_oa", "true"]]
@@ -37,23 +44,36 @@ const BOOLEAN_GROUP_BY_FIELDS = new Set([
37
44
  * canonical key. A record would hold only the last of them.
38
45
  *
39
46
  * Comma handling (commas collide with OpenAlex's filter clause separator):
40
- * - `*.search` keys: wrap the value in double quotes for faithful phrase passthrough
41
- * (skip if already quoted).
47
+ * - Free-text search keys (`title.search`, `abstract.search.exact`, `semantic.search`, …) and
48
+ * keys matched as whole text (`raw_affiliation_strings`, `display_name`, `geo.city`, …): the
49
+ * value goes out double-quoted as `quotedCommaValue()` writes it, so the comma stays part of the
50
+ * phrase. The quoted value is sent as written, `+` included.
42
51
  * - All other keys: throw a pre-flight validation error — OpenAlex OR-lists use `|`, not commas.
52
+ *
53
+ * Every other value goes out as `upstreamFilterValue()` writes it: country and `language` values
54
+ * converted to the codes OpenAlex stores (a country segment that cannot be a code throws
55
+ * `invalid_country_code`), a multi-word value on a key matched as whole text (`geo.city`,
56
+ * `awards.funder_display_name`, …) double-quoted so OpenAlex reads it as one phrase, and the AND
57
+ * operator — a `+`, or a space on a key where words mean AND — sent as the AND it spells. A value
58
+ * that puts that operator beside a `|` throws `mixed_and_or_in_filter_value`, since OpenAlex drops
59
+ * every `|` alternative that ANDs more than one value; a value sent double-quoted that carries a
60
+ * `"` of its own throws `quote_in_filter_value`. A value left unquoted with an unmatched `"`
61
+ * throws `unmatched_quote_in_filter_value`. Every throw runs before any request.
62
+ * `autocomplete()` sends its filters without `translateFilters()`, so the conversion lives here
63
+ * rather than there.
64
+ *
65
+ * Exported for the timing suite, which runs it on caller-sized keys and values.
43
66
  */
44
- function buildFilterString(filters) {
67
+ export function buildFilterString(filters) {
45
68
  return filters
46
69
  .map(([key, value]) => {
47
70
  if (value.includes(',')) {
48
- if (key.endsWith('.search')) {
49
- // Phrase passthrough: double-quote wrapping preserves the comma as free-text.
50
- const quoted = value.startsWith('"') && value.endsWith('"') ? value : `"${value}"`;
51
- return `${key}:${quoted}`;
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.`, { reason: 'comma_in_filter_value', filterKey: key });
71
+ const phrase = quotedCommaValue(key, value);
72
+ if (phrase !== undefined)
73
+ return `${key}:${phrase}`;
74
+ 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 });
55
75
  }
56
- return `${key}:${value}`;
76
+ return `${key}:${upstreamFilterValue(key, value)}`;
57
77
  })
58
78
  .join(',');
59
79
  }
@@ -89,27 +109,85 @@ const PMC_URL_PATTERN = /^https?:\/\/(?:www\.)?(?:pmc\.ncbi\.nlm\.nih\.gov|ncbi\
89
109
  */
90
110
  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.';
91
111
  /**
92
- * A keyword's own OpenAlex URL. Keywords are the only `ENTITY_TYPES` member OpenAlex addresses
93
- * by slug rather than a native letter-and-number ID, and their URL spells out the `keywords/`
94
- * path segment the endpoint already supplies — so stripping the host alone builds
95
- * `/keywords/keywords/<slug>`, which 404s, and the ID a search returns cannot be fed back into
96
- * its own lookup. The host is anchored on both ends so a look-alike domain is never read as
97
- * OpenAlex, and the slug group requires at least one character so a slugless path stays unrouted.
98
- */
99
- const OPENALEX_KEYWORD_URL_PATTERN = /^https:\/\/openalex\.org\/keywords\/([^/?#]+)\/?$/i;
100
- /**
101
- * Prefix `normalizeId()` emits → the single entity type that identifier scheme addresses.
102
- * Every external scheme OpenAlex indexes belongs to exactly one entity type, which is what
103
- * lets an identifier resolve without the caller naming a type.
104
- */
105
- const ENTITY_TYPE_BY_ID_PREFIX = {
106
- doi: 'works',
107
- issn: 'sources',
108
- orcid: 'authors',
109
- pmcid: 'works',
110
- pmid: 'works',
111
- ror: 'institutions',
112
+ * The start of an OpenAlex URL — `openalex.org`, `www.openalex.org`, or `api.openalex.org`, with
113
+ * an `http(s)://` scheme or none — gating the `URL` parse in `parseOpenAlexUrl()`. The lookahead
114
+ * anchors the host on the right, so a look-alike that only starts with it (`openalex.org.example`,
115
+ * `openalex.org:8443`) is never parsed, and the scheme anchors it on the left.
116
+ */
117
+ const OPENALEX_URL_START = /^(?:https?:\/\/)?(?:www\.|api\.)?openalex\.org(?=[/?#]|$)/i;
118
+ /** A native OpenAlex ID — one letter, then digits (`W2741809807`, the legacy venue `V137773608`). */
119
+ const NATIVE_ID_PATTERN = /^[a-z]\d+$/i;
120
+ /**
121
+ * Read the one ID an OpenAlex URL carries — a record page (`https://openalex.org/works/W…`), an
122
+ * API URL (`https://api.openalex.org/works/W…`), the short form (`https://openalex.org/W…`), or
123
+ * a keyword's `…/keywords/<slug>` — over `http://`, `https://`, or no scheme, ignoring a trailing
124
+ * `/`, the query string, and the fragment. The path must be `/<ID>` or `/<entity type>/<ID>`, a
125
+ * slug under `/keywords/`; anything else returns undefined and is left as the caller wrote it.
126
+ *
127
+ * Keywords are the one entity type OpenAlex addresses by slug, so their URL is the only spelling
128
+ * that identifies a keyword — and the entity-type segment has to go either way, since the request
129
+ * path supplies it again. A native ID's letter is upper-cased: OpenAlex IDs are upper-case, and
130
+ * the URL already says the value is one. A value with whitespace inside is refused before the
131
+ * parse, which would otherwise drop tabs and line breaks and join the pieces into an ID.
132
+ */
133
+ function parseOpenAlexUrl(value) {
134
+ if (!OPENALEX_URL_START.test(value) || /\s/.test(value))
135
+ return;
136
+ const { pathname } = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
137
+ const path = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
138
+ const [first = '', second, ...rest] = path.split('/').slice(1);
139
+ if (rest.length > 0)
140
+ return;
141
+ if (second === undefined) {
142
+ return NATIVE_ID_PATTERN.test(first) ? nativeId(first) : undefined;
143
+ }
144
+ const entityType = first.toLowerCase();
145
+ if (!isEntityType(entityType))
146
+ return;
147
+ if (entityType === 'keywords')
148
+ return second ? { id: second, kind: 'keyword' } : undefined;
149
+ return NATIVE_ID_PATTERN.test(second) ? nativeId(second) : undefined;
150
+ }
151
+ function nativeId(id) {
152
+ return { id: `${id.charAt(0).toUpperCase()}${id.slice(1)}`, kind: 'native' };
153
+ }
154
+ function isEntityType(value) {
155
+ return ENTITY_TYPES.includes(value);
156
+ }
157
+ /**
158
+ * Prefix `normalizeId()` emits → the entity types an identifier of that scheme can address, in
159
+ * the order `inferIdentifier()` looks them up. Most schemes belong to one type. A ROR names an
160
+ * organization, and OpenAlex keeps an organization's institution, funder, and publisher records
161
+ * apart under the same ROR — NSF's `021nxhr62` is all three — so a ROR can address any of them.
162
+ */
163
+ const ENTITY_TYPES_BY_ID_PREFIX = {
164
+ doi: ['works'],
165
+ issn: ['sources'],
166
+ orcid: ['authors'],
167
+ pmcid: ['works'],
168
+ pmid: ['works'],
169
+ ror: ['institutions', 'funders', 'publishers'],
112
170
  };
171
+ /**
172
+ * The Crossref Funder Registry's DOI prefix. Funders are keyed by these DOIs, which OpenAlex's
173
+ * `/funders/doi:` path does not resolve — only the `ids.doi` filter matches them, with the bare
174
+ * DOI. A few thousand works carry one too, by mis-attribution, so a funder DOI still reaches a
175
+ * work when the caller asks for works.
176
+ */
177
+ const FUNDER_REGISTRY_DOI_PREFIX = '10.13039/';
178
+ /**
179
+ * A DOI the funder `ids.doi` filter can match as one literal value: a DOI shape with nothing in
180
+ * it that filter syntax reads as structure — `|` (OR), `+` (AND), `,` (clause separator), or
181
+ * whitespace. A leading `!`, `<`, or `>` fails the `10.` anchor.
182
+ */
183
+ const FUNDER_DOI_FILTER_VALUE = /^10\.\d{4,}\/[^\s,|+]+$/;
184
+ /**
185
+ * The filter clause that matches a funder by its bare DOI, or undefined for a value that clause
186
+ * would read as more than one literal DOI — it could only match some other funder, or every one.
187
+ */
188
+ export function funderDoiFilter(doi) {
189
+ return FUNDER_DOI_FILTER_VALUE.test(doi) ? `ids.doi:${doi}` : undefined;
190
+ }
113
191
  /**
114
192
  * Detect ID format and return the API path segment.
115
193
  * "10.1038/nature12373" → "doi:10.1038/nature12373"
@@ -123,20 +201,16 @@ const ENTITY_TYPE_BY_ID_PREFIX = {
123
201
  * "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/" → "pmcid:PMC1234567"
124
202
  * "https://pubmed.ncbi.nlm.nih.gov/21491125" → "pmid:21491125"
125
203
  * "PMID:21491125" → "pmid:21491125"
126
- * "https://openalex.org/keywords/groundwater" → "groundwater"
204
+ * "https://openalex.org/works/W2741809807" → "W2741809807"
205
+ * "https://api.openalex.org/keywords/groundwater" → "groundwater"
127
206
  * "W2741809807" → "W2741809807"
128
207
  */
129
208
  export function normalizeId(id) {
130
209
  const trimmed = id.trim();
131
- // Keyword URL → its bare slug. Runs ahead of the generic OpenAlex strip, which would leave
132
- // the `keywords/` segment in place and double it against the endpoint path.
133
- const keywordSlug = OPENALEX_KEYWORD_URL_PATTERN.exec(trimmed)?.[1];
134
- if (keywordSlug) {
135
- return keywordSlug;
136
- }
137
- // Full OpenAlex URL
138
- if (trimmed.startsWith('https://openalex.org/')) {
139
- return trimmed.replace('https://openalex.org/', '');
210
+ // OpenAlex URL → the bare ID or keyword slug it carries (see parseOpenAlexUrl)
211
+ const openAlexId = parseOpenAlexUrl(trimmed);
212
+ if (openAlexId) {
213
+ return openAlexId.id;
140
214
  }
141
215
  // DOI URL
142
216
  if (trimmed.startsWith('https://doi.org/')) {
@@ -192,7 +266,7 @@ export function normalizeId(id) {
192
266
  const colon = trimmed.indexOf(':');
193
267
  if (colon > 0) {
194
268
  const scheme = trimmed.slice(0, colon).toLowerCase();
195
- if (Object.hasOwn(ENTITY_TYPE_BY_ID_PREFIX, scheme)) {
269
+ if (Object.hasOwn(ENTITY_TYPES_BY_ID_PREFIX, scheme)) {
196
270
  return `${scheme}${trimmed.slice(colon)}`;
197
271
  }
198
272
  }
@@ -214,10 +288,70 @@ const ENTITY_TYPE_BY_ID_LETTER = {
214
288
  * as *filter values* are deliberately absent here. `C` (the deprecated Concepts entity) and `G`
215
289
  * are not in `ENTITY_TYPES`, so there is no endpoint to fetch one from. `K` is in `ENTITY_TYPES`,
216
290
  * but OpenAlex identifies keywords by slug and emits no numeric K-prefixed IDs, so routing that
217
- * shape addressed nothing — `OPENALEX_KEYWORD_URL_PATTERN` is the keyword identifier. All three
218
- * fall through to autocomplete rather than being routed to a path that would 404.
291
+ * shape addressed nothing — a keyword URL is the keyword identifier. All three fall through to
292
+ * autocomplete rather than being routed to a path that would 404.
219
293
  */
220
294
  const ROUTABLE_OPENALEX_ID = /^([AFIPSTW])\d+$/;
295
+ /**
296
+ * A native ID whose letter names the entity type that holds it — every routable letter plus `C`,
297
+ * a concept ID, which names a type this server does not serve. Read case-insensitively: OpenAlex
298
+ * answers a lower-case ID as it does the upper-case one. `V` (a legacy venue ID that still
299
+ * resolves under `/sources/`), `K`, and `G` are absent, so `search()` checks none of them.
300
+ */
301
+ const LETTERED_OPENALEX_ID = /^([ACFIPSTW])\d+$/i;
302
+ /**
303
+ * Refuse a native ID whose letter names a different entity type than the lookup's. No record of
304
+ * one type carries another type's letter and every cross-letter lookup 404s upstream, so the
305
+ * request could only fail as a generic not-found; this names the type the ID belongs to instead.
306
+ *
307
+ * Keywords are exempt: their IDs are slugs, and real ones are shaped like native IDs (`P53`,
308
+ * `F1`, `S1`, `A1`), so on a keyword lookup a letter names nothing.
309
+ */
310
+ function assertIdMatchesEntityType(id, entityType) {
311
+ const letter = entityType === 'keywords' ? undefined : LETTERED_OPENALEX_ID.exec(id)?.[1];
312
+ if (letter === undefined)
313
+ return;
314
+ const idType = ENTITY_TYPE_BY_ID_LETTER[letter.toUpperCase()];
315
+ if (idType === entityType)
316
+ return;
317
+ const requested = `it identifies no record among ${entityType}`;
318
+ const shown = capIdEcho(id);
319
+ throw validationError(idType
320
+ ? `${shown} is an OpenAlex ${idType.replace(/s$/, '')} ID, which entity_type "${idType}" looks up — ${requested}.`
321
+ : `${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 } : {}) });
322
+ }
323
+ /**
324
+ * Characters a URL parse reads as structure rather than as part of a by-ID path's ID: `?` and
325
+ * `#` end the path, `\` separates segments on an `https:` URL, and tab, line feed, and carriage
326
+ * return are dropped outright, joining what they separated.
327
+ */
328
+ const ID_PATH_STRUCTURE = /[?#\\\t\n\r]/g;
329
+ /** A `.` or `..` path segment in any spelling a URL parse resolves — `%2e` is a dot. */
330
+ const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/i;
331
+ /** The most of a refused `id` an error message quotes; `data.id` keeps it whole. */
332
+ const ID_ECHO_MAX_CHARS = 200;
333
+ /** A refused `id` cut to `ID_ECHO_MAX_CHARS`, so a caller-sized one cannot bloat the message. */
334
+ function capIdEcho(id) {
335
+ return id.length > ID_ECHO_MAX_CHARS ? `${id.slice(0, ID_ECHO_MAX_CHARS)}…` : id;
336
+ }
337
+ /**
338
+ * The by-ID request path for `id`: `/<entityType>/<id>`, with every character a URL parse would
339
+ * read as structure percent-encoded so it stays part of the ID. OpenAlex decodes them back, so a
340
+ * DOI that holds a `#` or `?` reaches the record it names instead of a truncated one.
341
+ *
342
+ * A `.` or `..` segment, plain or percent-encoded, is refused before any request, since encoding
343
+ * cannot keep it inside the path. `/` is left as written, since DOIs carry it, and an encoded
344
+ * `%2F` is one segment to both the parse and OpenAlex. The refusal quotes `callerId`, the
345
+ * identifier as the caller wrote it, rather than the normalized `id` (`doi:…` for a DOI URL).
346
+ */
347
+ export function byIdPath(entityType, id, callerId = id) {
348
+ const encoded = id.replace(ID_PATH_STRUCTURE, encodeURIComponent);
349
+ const dots = encoded.split('/').find((segment) => DOT_SEGMENT.test(segment));
350
+ if (dots !== undefined) {
351
+ 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 });
352
+ }
353
+ return `/${entityType}/${encoded}`;
354
+ }
221
355
  /**
222
356
  * Classify a query as an identifier, or return `undefined` for a name.
223
357
  *
@@ -226,26 +360,47 @@ const ROUTABLE_OPENALEX_ID = /^([AFIPSTW])\d+$/;
226
360
  * it produced. Names never survive that: a name with a colon in it yields a prefix that is in no
227
361
  * scheme table, and anything else fails the native-ID pattern.
228
362
  *
229
- * Keywords are the exception, and are read from the raw query instead: their IDs are slugs, so
230
- * `normalizeId()` reduces the URL to a bare word that no longer names an entity type. The URL
231
- * form is the only keyword spelling that identifies itself — a bare slug is indistinguishable
232
- * from any other name, and autocomplete is the right answer for it.
233
- */
234
- export function inferIdentifier(query) {
235
- const keywordSlug = OPENALEX_KEYWORD_URL_PATTERN.exec(query.trim())?.[1];
236
- if (keywordSlug) {
237
- return { entityType: 'keywords', id: keywordSlug, scheme: 'openalex' };
363
+ * Keywords are the exception, and are read from the URL before it is reduced: their IDs are
364
+ * slugs, so `normalizeId()` turns the URL into a bare word that no longer names an entity type.
365
+ * The URL form is the only keyword spelling that identifies itself — a bare slug is
366
+ * indistinguishable from any other name, and autocomplete is the right answer for it.
367
+ *
368
+ * `requested` (the caller's `entity_type`) narrows an identifier only among the types it can
369
+ * address: a ROR to one of institutions, funders, and publishers, and a Funder Registry DOI to
370
+ * works. Any other type leaves the identifier's own types in place — except `keywords`, under
371
+ * which a bare native-ID shape is a name: keyword IDs are slugs, and `P53` is a keyword.
372
+ */
373
+ export function inferIdentifier(query, requested) {
374
+ const trimmed = query.trim();
375
+ const fromUrl = parseOpenAlexUrl(trimmed);
376
+ if (fromUrl?.kind === 'keyword') {
377
+ return { entityTypes: ['keywords'], id: fromUrl.id, scheme: 'openalex' };
238
378
  }
239
- const normalized = normalizeId(query);
379
+ if (fromUrl)
380
+ return nativeIdentifier(fromUrl.id);
381
+ const normalized = normalizeId(trimmed);
240
382
  const colon = normalized.indexOf(':');
241
383
  if (colon > 0) {
242
384
  const scheme = normalized.slice(0, colon).toLowerCase();
243
- const entityType = ownEntry(ENTITY_TYPE_BY_ID_PREFIX, scheme);
244
- return entityType ? { entityType, id: normalized, scheme } : undefined;
385
+ const types = ownEntry(ENTITY_TYPES_BY_ID_PREFIX, scheme);
386
+ if (!types)
387
+ return;
388
+ if (scheme === 'doi' && normalized.startsWith(FUNDER_REGISTRY_DOI_PREFIX, colon + 1)) {
389
+ return { entityTypes: [requested === 'works' ? 'works' : 'funders'], id: normalized, scheme };
390
+ }
391
+ const entityTypes = requested !== undefined && types.includes(requested) ? [requested] : types;
392
+ return { entityTypes, id: normalized, scheme };
245
393
  }
246
- const letter = ROUTABLE_OPENALEX_ID.exec(normalized)?.[1];
394
+ if (requested === 'keywords')
395
+ return;
396
+ const native = nativeIdentifier(normalized);
397
+ return native && { ...native, mayBeName: true };
398
+ }
399
+ /** A routable native OpenAlex ID as the identifier it is, or undefined for any other value. */
400
+ function nativeIdentifier(id) {
401
+ const letter = ROUTABLE_OPENALEX_ID.exec(id)?.[1];
247
402
  const entityType = letter ? ENTITY_TYPE_BY_ID_LETTER[letter] : undefined;
248
- return entityType ? { entityType, id: normalized, scheme: 'openalex' } : undefined;
403
+ return entityType ? { entityTypes: [entityType], id, scheme: 'openalex' } : undefined;
249
404
  }
250
405
  /**
251
406
  * Reconstruct abstract text from OpenAlex's inverted index format.
@@ -285,8 +440,47 @@ function normalizeEntityRecord(record) {
285
440
  abstract: normalizeProviderText(reconstructAbstract(abstract_inverted_index)),
286
441
  };
287
442
  }
288
- function normalizeEntityRecords(results) {
289
- return results.map(normalizeEntityRecord);
443
+ /** A string field of `value`, or null when it is absent or not a string. */
444
+ function stringOrNull(value, key) {
445
+ const field = value[key];
446
+ return typeof field === 'string' ? field : null;
447
+ }
448
+ /** One `author_list` entry: the authorship's author as `{id, display_name, orcid}`. */
449
+ function authorListEntry(authorship) {
450
+ const author = isRecord(authorship) && isRecord(authorship.author) ? authorship.author : {};
451
+ return {
452
+ id: stringOrNull(author, 'id'),
453
+ display_name: stringOrNull(author, 'display_name'),
454
+ orcid: stringOrNull(author, 'orcid'),
455
+ };
456
+ }
457
+ function authorListShape(entityType, select) {
458
+ if (entityType !== 'works' || !select?.includes(AUTHOR_LIST_FIELD))
459
+ return;
460
+ return {
461
+ keepAuthorships: select.some((field) => field === '*' ||
462
+ (field !== AUTHOR_LIST_FIELD && translateSelectField('works', field) === 'authorships')),
463
+ };
464
+ }
465
+ /**
466
+ * Add `author_list` to a normalized works record, derived from its `authorships`, and drop
467
+ * `authorships` unless the caller selected it too. A record OpenAlex returned without
468
+ * `authorships` gets no `author_list` — there is nothing to derive it from.
469
+ */
470
+ function withAuthorList(record, shape) {
471
+ const { authorships, ...rest } = record;
472
+ if (!Array.isArray(authorships))
473
+ return record;
474
+ return {
475
+ ...(shape.keepAuthorships ? record : rest),
476
+ [AUTHOR_LIST_FIELD]: authorships.map(authorListEntry),
477
+ };
478
+ }
479
+ function normalizeEntityRecords(results, authorList) {
480
+ return results.map((record) => {
481
+ const normalized = normalizeEntityRecord(record);
482
+ return authorList ? withAuthorList(normalized, authorList) : normalized;
483
+ });
290
484
  }
291
485
  function normalizeAutocompleteRecords(results) {
292
486
  return results.map((r) => normalizeProviderValue(r));
@@ -376,13 +570,22 @@ export function toAutocompleteRecord(entityType, record) {
376
570
  * output validation downstream of a successful API call.
377
571
  */
378
572
  const REQUIRED_SEARCH_FIELDS = ['id', 'display_name'];
573
+ /**
574
+ * The works `select` field the server derives rather than projects: each authorship's author as
575
+ * `{id, display_name, orcid}`, from the `authorships` it requests in its place. Not an upstream
576
+ * field, so it is never forwarded, and neither the default projection nor `*` includes it.
577
+ */
578
+ export const AUTHOR_LIST_FIELD = 'author_list';
379
579
  /**
380
580
  * Works-only `select` field aliases. LLM callers reach for intuitive names that don't match
381
581
  * OpenAlex's vocabulary; renaming on the way out keeps tool ergonomics aligned with caller
382
- * priors. Pure key renames — no value transformation. Canonical names pass through unchanged.
582
+ * priors. Canonical names pass through unchanged. Two names arrive under another key:
583
+ * `abstract`, reconstructed from `abstract_inverted_index`, and `author_list`, reduced from
584
+ * `authorships` — every other alias is a pure key rename.
383
585
  */
384
586
  const SELECT_ALIASES_WORKS = {
385
587
  abstract: 'abstract_inverted_index',
588
+ [AUTHOR_LIST_FIELD]: 'authorships',
386
589
  authors: 'authorships',
387
590
  year: 'publication_year',
388
591
  };
@@ -399,16 +602,26 @@ const SELECT_ALIASES_SUMMARY_STATS = {
399
602
  i10_index: 'summary_stats',
400
603
  };
401
604
  /**
402
- * Entity types that expose `summary_stats`, verified against `api.openalex.org`. `works`,
403
- * `topics`, and `keywords` carry no such field and reject it in `select`, so they get no
404
- * alias map — the upstream 400 is the correct answer there.
605
+ * Sources-only `select` aliases: the `summary_stats` leaves, plus `publisher`. OpenAlex names a
606
+ * journal's publisher `host_organization_name` — for a repository it names the hosting
607
+ * institution — and token-overlap suggestions cannot reach it from `publisher`. Select only: the
608
+ * filter, sort, and group_by sides have no single target (`host_organization` and
609
+ * `host_organization_lineage` both read as "publisher"), so `publisher` there keeps its 400.
610
+ */
611
+ const SELECT_ALIASES_SOURCES = {
612
+ ...SELECT_ALIASES_SUMMARY_STATS,
613
+ publisher: 'host_organization_name',
614
+ };
615
+ /**
616
+ * Entity types besides sources that expose `summary_stats`, verified against
617
+ * `api.openalex.org`. `works`, `topics`, and `keywords` carry no such field and reject it in
618
+ * `select`, so they get no alias map — the upstream 400 is the correct answer there.
405
619
  */
406
620
  const SUMMARY_STATS_ENTITY_TYPES = new Set([
407
621
  'authors',
408
622
  'funders',
409
623
  'institutions',
410
624
  'publishers',
411
- 'sources',
412
625
  ]);
413
626
  /**
414
627
  * Works-only `filter` key aliases. Same shape as `SELECT_ALIASES_WORKS` — LLM callers reach
@@ -428,26 +641,38 @@ const FILTER_ALIASES_WORKS = {
428
641
  * name. Keeps tool ergonomics symmetric with the response shape.
429
642
  *
430
643
  * Renames are fail-open: an unmapped name passes through and surfaces upstream's 400 with
431
- * its valid-field list. Several leaves can share one parent, so the list is deduped after
432
- * aliasing rather than before.
644
+ * its valid-field list. Several names can share one upstream field (summary_stats leaves;
645
+ * `author_list` beside `authorships`), so the list is deduped after aliasing rather than before.
433
646
  */
434
647
  function translateSelect(entityType, fields) {
435
648
  const upstream = [...REQUIRED_SEARCH_FIELDS, ...fields].map((field) => translateSelectField(entityType, field));
436
649
  return Array.from(new Set(upstream));
437
650
  }
438
651
  /**
439
- * The upstream field one caller-supplied `select` name projects to — `authors` → `authorships`
440
- * on works, `h_index` → `summary_stats` where that object exists; any other name unchanged.
441
- * Exported so a tool can find the record key a one-field projection lands under.
652
+ * The upstream field one caller-supplied `select` name projects to — `authors` and
653
+ * `author_list` → `authorships` on works, `publisher` → `host_organization_name` on sources,
654
+ * `h_index` → `summary_stats` where that object exists; any other name unchanged.
442
655
  */
443
- export function translateSelectField(entityType, field) {
656
+ function translateSelectField(entityType, field) {
444
657
  const aliases = entityType === 'works'
445
658
  ? SELECT_ALIASES_WORKS
446
- : SUMMARY_STATS_ENTITY_TYPES.has(entityType)
447
- ? SELECT_ALIASES_SUMMARY_STATS
448
- : undefined;
659
+ : entityType === 'sources'
660
+ ? SELECT_ALIASES_SOURCES
661
+ : SUMMARY_STATS_ENTITY_TYPES.has(entityType)
662
+ ? SELECT_ALIASES_SUMMARY_STATS
663
+ : undefined;
449
664
  return ownEntry(aliases, field) ?? field;
450
665
  }
666
+ /**
667
+ * The record key one caller-supplied `select` name lands under: `author_list` keeps its own
668
+ * name; any other name lands under the upstream field it projects (`authors` → `authorships`).
669
+ * Exported so a tool can find the array a one-field projection returns.
670
+ */
671
+ export function selectRecordKey(entityType, field) {
672
+ return entityType === 'works' && field === AUTHOR_LIST_FIELD
673
+ ? field
674
+ : translateSelectField(entityType, field);
675
+ }
451
676
  /**
452
677
  * A table's own entry for `key`, or undefined. The alias and scheme tables are plain object
453
678
  * literals, so a bracket read of a caller-supplied `constructor` or `__proto__` returns the
@@ -473,19 +698,26 @@ function resolveSelect(entityType, select) {
473
698
  return translateSelect(entityType, select?.length ? select : DEFAULT_SELECT[entityType]);
474
699
  }
475
700
  /**
476
- * Match a single bare or URL-form OpenAlex ID. Combined with pipe-splitting in
477
- * `isOpenAlexFilterValue` to detect OR-joined ID lists.
701
+ * A bare OpenAlex ID the `openalex` filter accepts as a value, in either case. Combined with
702
+ * `parseOpenAlexUrl()` for the URL spellings and with pipe-splitting in `isOpenAlexFilterValue`
703
+ * to detect OR-joined ID lists.
478
704
  */
479
- const OPENALEX_ID_VALUE_PATTERN = /^(?:https:\/\/openalex\.org\/)?[WAISTKPFCG]\d+$/;
705
+ const OPENALEX_ID_VALUE_PATTERN = /^[WAISTKPFCG]\d+$/i;
480
706
  /**
481
- * Detect a value the `id:` filter would carry — a bare or URL-form OpenAlex ID, optionally
482
- * pipe-joined for OR semantics. When matched, we rewrite `id` → `openalex` (the canonical
483
- * filter key); the URL form passes through unchanged because upstream accepts both.
484
- * Stricter than a single regex so a future generic upstream `id:` filter wouldn't be shadowed
485
- * by our rewrite — only values that look like OpenAlex IDs get redirected.
707
+ * Detect a value the `id:` filter would carry — OpenAlex IDs, bare or in any URL spelling
708
+ * `parseOpenAlexUrl()` reads, optionally pipe-joined for OR semantics. When matched, `id` is
709
+ * rewritten to `openalex` (the canonical filter key) and the value is forwarded as written:
710
+ * upstream accepts every one of those spellings, and the space around a `|`. Stricter than any
711
+ * value so a future generic upstream `id:` filter wouldn't be shadowed — only values that read
712
+ * as OpenAlex IDs get redirected. Exported for the timing suite, which runs it on caller-sized
713
+ * values.
486
714
  */
487
- function isOpenAlexFilterValue(value) {
488
- return value.split('|').every((seg) => OPENALEX_ID_VALUE_PATTERN.test(seg));
715
+ export function isOpenAlexFilterValue(value) {
716
+ return value.split('|').every((segment) => {
717
+ const trimmed = segment.trim();
718
+ const fromUrl = parseOpenAlexUrl(trimmed);
719
+ return OPENALEX_ID_VALUE_PATTERN.test(fromUrl?.kind === 'native' ? fromUrl.id : trimmed);
720
+ });
489
721
  }
490
722
  /**
491
723
  * Rewrite one caller-supplied filter key to the upstream OpenAlex name, before the
@@ -522,9 +754,29 @@ function translateFilters(entityType, filters) {
522
754
  }
523
755
  return out;
524
756
  }
757
+ /**
758
+ * The `filter` parameter a search or group_by over `filters` sends: keys translated, then each
759
+ * clause built by `buildFilterString()`, whose pre-flight throws (`comma_in_filter_value`,
760
+ * `invalid_country_code`, `mixed_and_or_in_filter_value`, `quote_in_filter_value`,
761
+ * `unmatched_quote_in_filter_value`) it shares.
762
+ * Exported so a tool that spends a request before its search — the citation graph's seed lookup —
763
+ * can run those checks first and fail without one.
764
+ */
765
+ export function buildSearchFilter(entityType, filters) {
766
+ return buildFilterString(translateFilters(entityType, filters));
767
+ }
525
768
  const MAX_ATTEMPTS = 3;
526
769
  const BASE_BACKOFF_MS = 1000;
527
770
  const REQUEST_TIMEOUT_MS = 10_000;
771
+ /**
772
+ * How long an identifier lookup and the name search it falls back to may take together, counted
773
+ * from the start of the lookup. One request's own worst case — three 10 s attempts and up to
774
+ * ~3.75 s of jittered backoff — is ~34 s, so a lookup that misses inside its retry budget still
775
+ * leaves the name search one full attempt, and the call settles by 45 s, inside the 60 s an MCP
776
+ * client waits. A lookup that ran past it (a long upstream `Retry-After`) leaves no time, and
777
+ * the name search reports a timeout without being sent.
778
+ */
779
+ const NAME_FALLBACK_DEADLINE_MS = 45_000;
528
780
  const KNOWN_AUTOCOMPLETE_TYPES = new Set(ENTITY_TYPES.map((type) => type.replace(/s$/, '')));
529
781
  /**
530
782
  * `JsonRpcErrorCode` → `{ factory, reason }` for shaping normalized OpenAlex throws.
@@ -750,6 +1002,42 @@ function overLongAutocompleteQuery(path, params) {
750
1002
  const codePoints = [...params.q].length;
751
1003
  return codePoints > AUTOCOMPLETE_QUERY_MAX_CODE_POINTS ? codePoints : undefined;
752
1004
  }
1005
+ /**
1006
+ * The start of the HTTP 400 `message` OpenAlex sends when a semantic search's query-embedding
1007
+ * call fails for any reason but a timeout — `APIQueryParamsError(f"Failed to embed query: {e}")`
1008
+ * in ourresearch/openalex-elastic-api `core/semantic_search.py` (`embed_query`). The rest is the
1009
+ * embedding client's exception text: an HTTP error from the embedding service (its own 429 among
1010
+ * them, which is not this caller's rate), a connection failure, or a parse error — every one an
1011
+ * upstream fault, and one that names the embedding service's host.
1012
+ */
1013
+ const SEMANTIC_EMBED_FAILURE_PREFIX = 'Failed to embed query:';
1014
+ /**
1015
+ * The HTTP 400 `message` OpenAlex sends when its embedding service is not configured (same module,
1016
+ * `get_embedding_client`) — an upstream fault worded as a parameter error.
1017
+ */
1018
+ const SEMANTIC_NOT_CONFIGURED_MESSAGE = 'Semantic search is not configured';
1019
+ /**
1020
+ * Recovery for every `upstream_unavailable` failure of a semantic search — a relabelled embedding
1021
+ * failure, the 503 OpenAlex sends when the embedding timed out, or any other outage — since a
1022
+ * keyword search does not depend on the embedding service and can answer while it is down.
1023
+ */
1024
+ 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.';
1025
+ /** Is this request a semantic search? `search.semantic` is the parameter only that mode sends. */
1026
+ function isSemanticSearch(params) {
1027
+ return params['search.semantic'] !== undefined;
1028
+ }
1029
+ /**
1030
+ * Did OpenAlex fail a semantic search's query embedding? Matched on the request mode and on the
1031
+ * whole `message` shape — anchored at its start — so no caller-error 400 (an unsupported semantic
1032
+ * filter, a missing query, two search methods at once) and no keyword-mode 400 ever matches.
1033
+ */
1034
+ function isSemanticEmbedFailure(statusCode, rawMessage, params) {
1035
+ return (statusCode === 400 &&
1036
+ isSemanticSearch(params) &&
1037
+ rawMessage !== undefined &&
1038
+ (rawMessage.startsWith(SEMANTIC_EMBED_FAILURE_PREFIX) ||
1039
+ rawMessage === SEMANTIC_NOT_CONFIGURED_MESSAGE));
1040
+ }
753
1041
  /** Reason for a 429 caused by daily-budget exhaustion rather than burst throttling. */
754
1042
  const BUDGET_EXHAUSTED_REASON = 'upstream_budget_exhausted';
755
1043
  /**
@@ -760,9 +1048,31 @@ const BUDGET_EXHAUSTED_REASON = 'upstream_budget_exhausted';
760
1048
  */
761
1049
  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.";
762
1050
  /**
763
- * OpenAlex returns 429 for two unrelated conditions: bursting past its per-second ceiling,
764
- * and spending the daily usage budget. The recoveries are opposites — one clears in seconds,
765
- * the other not until the midnight-UTC reset — so they need separate reasons.
1051
+ * The `error` field of the HTTP 429 OpenAlex answers a filter its paid plans gate with, observed
1052
+ * live (2026-10-18) for `created_date`, `updated_date`, and their `from_`/`to_` forms on works and
1053
+ * authors lists, a works group_by, and works autocomplete. Its `message` names the refused clause:
1054
+ * `The "from_created_date:2026-10-01" filter is part of daily sync, which comes with the Member+
1055
+ * and Partner plans. See https://help.openalex.org/access/sync/ for details.` It is a plan gate,
1056
+ * not a rate: no wait clears it.
1057
+ */
1058
+ const PLAN_UPGRADE_REQUIRED_ERROR = 'Plan upgrade required';
1059
+ /** The filter name opening a plan-gated 429's `message`: `The "<filter>:<value>" filter …`. */
1060
+ const PLAN_GATED_FILTER_RE = /^The "([\w.]+):/;
1061
+ /**
1062
+ * The server's own message for a plan-gated filter, naming the filter when the upstream message
1063
+ * opens with it. The name is read as a field-name run only, so caller text the upstream clause
1064
+ * echoes cannot carry markdown or a line break into the message.
1065
+ */
1066
+ export function planGatedFilterMessage(upstreamMessage) {
1067
+ const filter = upstreamMessage === undefined ? undefined : PLAN_GATED_FILTER_RE.exec(upstreamMessage)?.[1];
1068
+ const subject = filter === undefined ? 'A filter in this request' : `The \`${filter}\` filter`;
1069
+ return `${subject} needs a paid OpenAlex plan, which this server's OpenAlex access does not include.`;
1070
+ }
1071
+ /**
1072
+ * Besides a filter its paid plans gate (classified before this, on its body's `error`), OpenAlex
1073
+ * returns 429 for two unrelated conditions: bursting past its per-second ceiling, and spending
1074
+ * the daily usage budget. The recoveries are opposites — one clears in seconds, the other not
1075
+ * until the midnight-UTC reset — so they need separate reasons.
766
1076
  *
767
1077
  * The literal budget-exhaustion body is not published in OpenAlex's docs, so the match is
768
1078
  * deliberately loose: several independent signals, none of them load-bearing on an exact
@@ -844,11 +1154,7 @@ function appendFieldSuggestions(message, path) {
844
1154
  const entityType = entityTypeFromPath(path);
845
1155
  if (!entityType)
846
1156
  return message;
847
- const context = inferContext(message);
848
- const catalogEntry = fieldCatalog[entityType];
849
- if (!catalogEntry)
850
- return message;
851
- const pool = catalogEntry[context];
1157
+ const pool = getFieldCatalog()[entityType][inferContext(message)];
852
1158
  const suggestions = rankFields(rejectedField, pool, 3);
853
1159
  if (suggestions.length === 0)
854
1160
  return message;
@@ -902,8 +1208,12 @@ class OpenAlexService {
902
1208
  this.apiKey = config.apiKey;
903
1209
  this.mailto = config.mailto;
904
1210
  }
905
- /** Execute an HTTP request against the OpenAlex API with retry on transient failures. */
906
- request(path, params, ctx) {
1211
+ /**
1212
+ * Execute an HTTP request against the OpenAlex API with retry on transient failures. With
1213
+ * `deadlineMs`, every attempt and backoff settles within that many milliseconds, after which
1214
+ * the request fails as a `Timeout`.
1215
+ */
1216
+ request(path, params, ctx, deadlineMs) {
907
1217
  const operation = `OpenAlex ${path}`;
908
1218
  const url = new URL(`${this.baseUrl}${path}`);
909
1219
  // OpenAlex usage-based pricing (Feb 2026): the account credential authenticates as
@@ -948,6 +1258,7 @@ class OpenAlexService {
948
1258
  baseDelayMs: BASE_BACKOFF_MS,
949
1259
  maxRetries: MAX_ATTEMPTS - 1,
950
1260
  signal: ctx.signal,
1261
+ ...(deadlineMs !== undefined && { deadlineMs }),
951
1262
  });
952
1263
  }
953
1264
  throwNormalizedRequestError(error, path, params) {
@@ -955,6 +1266,12 @@ class OpenAlexService {
955
1266
  throw error;
956
1267
  }
957
1268
  const statusCode = typeof error.data?.statusCode === 'number' ? error.data.statusCode : undefined;
1269
+ const upstream = parseOpenAlexErrorBody(error.data?.responseBody);
1270
+ const rawMessage = upstream?.message ?? upstream?.error;
1271
+ const upstreamFields = {
1272
+ ...(upstream?.error ? { upstreamError: upstream.error } : {}),
1273
+ ...(upstream?.message ? { upstreamMessage: upstream.message } : {}),
1274
+ };
958
1275
  // A typed autocomplete `q` past the bound 500s on every attempt, with an HTML body that
959
1276
  // names nothing — read as ServiceUnavailable it would be retried and reported as an outage.
960
1277
  // `retryable: false` keeps it out of `withRetry`, and the recovery says to shorten the name.
@@ -962,6 +1279,33 @@ class OpenAlexService {
962
1279
  if (overLongQuery !== undefined) {
963
1280
  throw invalidParams(`OpenAlex autocomplete failed on a ${overLongQuery.toLocaleString('en-US')}-character query — ${path} accepts at most ${AUTOCOMPLETE_QUERY_MAX_CODE_POINTS.toLocaleString('en-US')} characters.`, { ...error.data, path, reason: 'query_too_long', retryable: false }, { cause: error });
964
1281
  }
1282
+ // A semantic search whose query embedding failed upstream comes back as a 400, but the request
1283
+ // was valid and the fault is transient. Thrown as the outage it is, it is retried like one, and
1284
+ // its message is the server's own: the upstream one names the embedding service's host. The
1285
+ // body stays verbatim on `data`, as on every upstream error.
1286
+ if (isSemanticEmbedFailure(statusCode, rawMessage, params)) {
1287
+ throw serviceUnavailable('OpenAlex could not embed the query for semantic search. The failure is upstream; the request itself is valid.', {
1288
+ ...error.data,
1289
+ path,
1290
+ reason: 'upstream_unavailable',
1291
+ recovery: { hint: SEMANTIC_UNAVAILABLE_HINT },
1292
+ ...upstreamFields,
1293
+ }, { cause: error });
1294
+ }
1295
+ // A filter OpenAlex serves only on a paid plan comes back as a 429, which reads as a throttle:
1296
+ // it would be retried and reported with wait-and-retry advice, yet no wait clears a plan gate.
1297
+ // `Forbidden` sits outside `withRetry`'s transient set, so the first request is the only one,
1298
+ // and `retryable: false` tells the caller the same over the 429 status left on `data`. The
1299
+ // message is the server's own; the body stays verbatim on `data`, as on every upstream error.
1300
+ if (statusCode === 429 && upstream?.error === PLAN_UPGRADE_REQUIRED_ERROR) {
1301
+ throw forbidden(planGatedFilterMessage(upstream.message), {
1302
+ ...error.data,
1303
+ path,
1304
+ reason: 'upstream_plan_required',
1305
+ retryable: false,
1306
+ ...upstreamFields,
1307
+ }, { cause: error });
1308
+ }
965
1309
  // Status-mapped failures classify off the HTTP status. Everything else — the client-side
966
1310
  // `REQUEST_TIMEOUT_MS` abort, DNS/connection failures, and the `parseResponse` throws
967
1311
  // (empty body, HTML, invalid JSON) — never carries a status, so the error's own code is
@@ -980,8 +1324,6 @@ class OpenAlexService {
980
1324
  });
981
1325
  }
982
1326
  const { factory } = normalized;
983
- const upstream = parseOpenAlexErrorBody(error.data?.responseBody);
984
- const rawMessage = upstream?.message ?? upstream?.error;
985
1327
  const reason = resolveThrowReason(code, normalized.reason, rawMessage, params);
986
1328
  // A truncated body's appended valid-fields list is misleading. For an invalid-field
987
1329
  // 400, replace it with catalog-backed ranked suggestions; otherwise strip it. The two
@@ -1023,8 +1365,12 @@ class OpenAlexService {
1023
1365
  ...(reason === BUDGET_EXHAUSTED_REASON && !this.apiKey
1024
1366
  ? { recovery: { hint: BUDGET_EXHAUSTED_KEYLESS_HINT } }
1025
1367
  : {}),
1026
- ...(upstream?.error ? { upstreamError: upstream.error } : {}),
1027
- ...(upstream?.message ? { upstreamMessage: upstream.message } : {}),
1368
+ // A keyword search can answer while semantic search is down, which the contract's generic
1369
+ // outage recovery cannot say; an explicit hint wins over it.
1370
+ ...(reason === 'upstream_unavailable' && isSemanticSearch(params)
1371
+ ? { recovery: { hint: SEMANTIC_UNAVAILABLE_HINT } }
1372
+ : {}),
1373
+ ...upstreamFields,
1028
1374
  };
1029
1375
  throw factory(message, data, { cause: error });
1030
1376
  }
@@ -1063,11 +1409,15 @@ class OpenAlexService {
1063
1409
  // Singleton lookup by ID
1064
1410
  if (params.id) {
1065
1411
  const normalizedId = normalizeId(params.id);
1412
+ assertIdMatchesEntityType(normalizedId, params.entityType);
1066
1413
  const select = resolveSelect(params.entityType, params.select);
1067
- const data = (await this.request(`/${params.entityType}/${normalizedId}`, select ? { select: select.join(',') } : {}, ctx));
1414
+ if (params.entityType === 'funders' && normalizedId.startsWith('doi:')) {
1415
+ return this.funderByDoi(normalizedId.slice('doi:'.length), select, ctx);
1416
+ }
1417
+ const data = (await this.request(byIdPath(params.entityType, normalizedId, params.id), select ? { select: select.join(',') } : {}, ctx));
1068
1418
  return {
1069
1419
  meta: { count: 1, per_page: 1, next_cursor: null },
1070
- results: normalizeEntityRecords([data]),
1420
+ results: normalizeEntityRecords([data], authorListShape(params.entityType, params.select)),
1071
1421
  };
1072
1422
  }
1073
1423
  // Search/filter/list
@@ -1076,7 +1426,7 @@ class OpenAlexService {
1076
1426
  queryParams[getSearchParamKey(params.searchMode)] = params.query;
1077
1427
  }
1078
1428
  if (hasEntries(params.filters)) {
1079
- queryParams.filter = buildFilterString(translateFilters(params.entityType, params.filters));
1429
+ queryParams.filter = buildSearchFilter(params.entityType, params.filters);
1080
1430
  }
1081
1431
  const sort = normalizeSort(params.sort);
1082
1432
  if (sort) {
@@ -1130,17 +1480,40 @@ class OpenAlexService {
1130
1480
  ...(populationData !== undefined && { count: populationData.meta.count }),
1131
1481
  next_cursor: data.meta.next_cursor ?? null,
1132
1482
  },
1133
- results: normalizeEntityRecords(data.results),
1483
+ results: normalizeEntityRecords(data.results, authorListShape(params.entityType, params.select)),
1484
+ };
1485
+ }
1486
+ /**
1487
+ * Look a funder up by its Crossref Funder Registry DOI. OpenAlex's `/funders/doi:` path
1488
+ * resolves nothing, so this is a one-result list request on the `ids.doi` filter, which OpenAlex
1489
+ * bills like any list request. A value the filter would read as more than one literal DOI fails
1490
+ * as not found without a request (see `funderDoiFilter`).
1491
+ */
1492
+ async funderByDoi(doi, select, ctx) {
1493
+ const missing = () => notFound(`No funder in OpenAlex carries the DOI ${doi}.`, {
1494
+ reason: 'entity_not_found',
1495
+ path: '/funders',
1496
+ });
1497
+ const filter = funderDoiFilter(doi);
1498
+ if (!filter)
1499
+ throw missing();
1500
+ const data = (await this.request('/funders', { filter, per_page: '1', ...(select ? { select: select.join(',') } : {}) }, ctx));
1501
+ const [record] = data.results;
1502
+ if (!record)
1503
+ throw missing();
1504
+ return {
1505
+ meta: { count: 1, per_page: 1, next_cursor: null },
1506
+ results: normalizeEntityRecords([record]),
1134
1507
  };
1135
1508
  }
1136
1509
  /** Group-by aggregation. */
1137
1510
  async analyze(params, ctx) {
1138
1511
  const queryParams = {};
1139
- queryParams.group_by = params.includeUnknown
1140
- ? `${params.groupBy}:include_unknown`
1141
- : params.groupBy;
1512
+ const baseField = params.groupBy.replace(/:include_unknown$/, '');
1513
+ const includeUnknown = params.includeUnknown === true || baseField !== params.groupBy;
1514
+ queryParams.group_by = includeUnknown ? `${baseField}:include_unknown` : baseField;
1142
1515
  if (hasEntries(params.filters)) {
1143
- queryParams.filter = buildFilterString(translateFilters(params.entityType, params.filters));
1516
+ queryParams.filter = buildSearchFilter(params.entityType, params.filters);
1144
1517
  }
1145
1518
  if (params.perPage !== undefined) {
1146
1519
  queryParams.per_page = String(params.perPage);
@@ -1154,9 +1527,7 @@ class OpenAlexService {
1154
1527
  // - key-asc (order: "key"): send cursor=* on the first page. OpenAlex returns groups
1155
1528
  // in key-ascending order and emits a `next_cursor` for full enumeration.
1156
1529
  // - subsequent pages: forward the caller-supplied cursor as-is.
1157
- const baseField = params.groupBy.replace(/:include_unknown$/, '');
1158
- const isBooleanGroupBy = BOOLEAN_GROUP_BY_FIELDS.has(baseField);
1159
- const keyAscOrder = !isBooleanGroupBy && params.order === 'key';
1530
+ const keyAscOrder = !isBooleanGroupBy(baseField) && params.order === 'key';
1160
1531
  if (params.cursor) {
1161
1532
  queryParams.cursor = params.cursor;
1162
1533
  }
@@ -1178,11 +1549,13 @@ class OpenAlexService {
1178
1549
  groups_count: data.meta.groups_count ?? data.group_by.length,
1179
1550
  next_cursor: data.meta.next_cursor ?? null,
1180
1551
  },
1181
- groups: data.group_by.map((group) => normalizeGroup(group, params.includeUnknown === true)),
1552
+ groups: data.group_by.map((group) => normalizeGroup(group, includeUnknown)),
1182
1553
  };
1183
1554
  }
1184
1555
  /**
1185
- * Resolve an identifier to the single entity it addresses, shaped as an autocomplete match.
1556
+ * Resolve an identifier to the entities it addresses, shaped as autocomplete matches — one
1557
+ * lookup per entity type in `identifier.entityTypes`, run in parallel, with the records found
1558
+ * returned in that order.
1186
1559
  *
1187
1560
  * Autocomplete matches on `display_name` and the external-ID URL, so whether an identifier
1188
1561
  * resolves through it depends on scheme, bare-vs-URL form, and entity type — a bare ORCID or
@@ -1193,26 +1566,90 @@ class OpenAlexService {
1193
1566
  * A miss returns no results rather than throwing. Autocomplete answers an unmatched query with
1194
1567
  * an empty list, and routing identifiers through here is meant to make them behave *more* like
1195
1568
  * the front door, not to hand one class of query a hard failure the rest never sees.
1569
+ *
1570
+ * A lookup that fails for any other reason drops only its own type, named in
1571
+ * `failedEntityTypes`, while another type returned a record. With no record to return, or once
1572
+ * the caller has cancelled, the failure is thrown instead — an empty or partial answer there
1573
+ * would claim records do not exist when the lookup for them never finished.
1574
+ *
1575
+ * `nameSearch` is the autocomplete to run when every lookup finds nothing — for a query that
1576
+ * may be a name rather than an ID (`ResolvedIdentifier.mayBeName`). It runs only on that miss,
1577
+ * so an ID that resolves still costs its one request, and only within what is left of
1578
+ * `NAME_FALLBACK_DEADLINE_MS` from the start of this call (see `nameSearchAfterMiss`).
1579
+ *
1580
+ * `query` is the text `identifier` was read from, as the caller wrote it. The lookups are sent
1581
+ * that spelling, which `search()` normalizes back to `identifier.id`, so a lookup that refuses
1582
+ * the identifier quotes what the caller sent.
1196
1583
  */
1197
- async resolveIdentifier(identifier, ctx) {
1198
- try {
1199
- const { results } = await this.search({ entityType: identifier.entityType, id: identifier.id }, ctx);
1200
- const record = results[0];
1201
- return { results: record ? [toAutocompleteRecord(identifier.entityType, record)] : [] };
1202
- }
1203
- catch (error) {
1204
- if (error instanceof McpError && error.code === JsonRpcErrorCode.NotFound) {
1584
+ async resolveIdentifier(identifier, ctx, nameSearch, query = identifier.id) {
1585
+ const startedAt = Date.now();
1586
+ const settled = await Promise.allSettled(identifier.entityTypes.map((entityType) => this.search({ entityType, id: query }, ctx)));
1587
+ const results = [];
1588
+ const failed = [];
1589
+ settled.forEach((outcome, index) => {
1590
+ const entityType = identifier.entityTypes[index];
1591
+ if (outcome.status === 'fulfilled') {
1592
+ const [record] = outcome.value.results;
1593
+ if (record)
1594
+ results.push(toAutocompleteRecord(entityType, record));
1595
+ }
1596
+ else if (outcome.reason instanceof McpError &&
1597
+ outcome.reason.code === JsonRpcErrorCode.NotFound) {
1205
1598
  ctx.log.debug('Identifier resolved to no entity', {
1206
1599
  identifier: identifier.id,
1207
- entityType: identifier.entityType,
1600
+ entityType,
1208
1601
  });
1209
- return { results: [] };
1210
1602
  }
1211
- throw error;
1603
+ else {
1604
+ failed.push({ entityType, error: outcome.reason });
1605
+ }
1606
+ });
1607
+ const [firstFailure] = failed;
1608
+ if (firstFailure && (results.length === 0 || ctx.signal.aborted))
1609
+ throw firstFailure.error;
1610
+ if (results.length === 0 && nameSearch) {
1611
+ return this.nameSearchAfterMiss(nameSearch, NAME_FALLBACK_DEADLINE_MS - (Date.now() - startedAt), ctx);
1612
+ }
1613
+ return {
1614
+ results,
1615
+ ...(failed.length > 0 ? { failedEntityTypes: failed.map((f) => f.entityType) } : {}),
1616
+ };
1617
+ }
1618
+ /**
1619
+ * The name search an identifier lookup falls back to after finding nothing, bounded by the
1620
+ * `remainingMs` the lookup left of the call's deadline. It is a best-effort second reading of
1621
+ * the query, so a transient upstream failure — a timeout, the deadline running out, an outage,
1622
+ * a burst throttle — comes back as `nameFallback.failedReason` for the caller to see, beside
1623
+ * the lookup's own true miss. A caller cancellation, a request upstream rejected, a spent
1624
+ * budget, and anything unclassified are thrown: none of them is cured by calling again, and an
1625
+ * answer in their place would hide the error the caller needs to act on.
1626
+ */
1627
+ async nameSearchAfterMiss(params, remainingMs, ctx) {
1628
+ if (remainingMs <= 0) {
1629
+ return { results: [], nameFallback: { failedReason: 'upstream_timeout' } };
1630
+ }
1631
+ try {
1632
+ const { results } = await this.autocomplete(params, ctx, remainingMs);
1633
+ return { results, nameFallback: {} };
1634
+ }
1635
+ catch (error) {
1636
+ const reason = error instanceof McpError && defaultIsTransient(error)
1637
+ ? NORMALIZED_THROW_BY_CODE[error.code]?.reason
1638
+ : undefined;
1639
+ if (ctx.signal.aborted || reason === undefined)
1640
+ throw error;
1641
+ ctx.log.notice('Name search after an identifier miss failed upstream', {
1642
+ query: params.query,
1643
+ reason,
1644
+ });
1645
+ return { results: [], nameFallback: { failedReason: reason } };
1212
1646
  }
1213
1647
  }
1214
- /** Autocomplete name resolution. */
1215
- async autocomplete(params, ctx) {
1648
+ /**
1649
+ * Autocomplete name resolution. `deadlineMs` bounds the whole request, retries included (see
1650
+ * `request`).
1651
+ */
1652
+ async autocomplete(params, ctx, deadlineMs) {
1216
1653
  const path = params.entityType ? `/autocomplete/${params.entityType}` : '/autocomplete';
1217
1654
  const queryParams = {
1218
1655
  q: params.query,
@@ -1220,7 +1657,7 @@ class OpenAlexService {
1220
1657
  if (hasEntries(params.filters)) {
1221
1658
  queryParams.filter = buildFilterString(Object.entries(params.filters));
1222
1659
  }
1223
- const data = (await this.request(path, queryParams, ctx));
1660
+ const data = (await this.request(path, queryParams, ctx, deadlineMs));
1224
1661
  // Cross-entity autocomplete can return types not in our enum (country, license, etc.).
1225
1662
  // Filter to known entity types so callers can use results in other tools directly.
1226
1663
  const results = params.entityType
@@ -1238,6 +1675,15 @@ export function getOpenAlexService() {
1238
1675
  throw new Error('OpenAlexService not initialized — call initOpenAlexService() in setup()');
1239
1676
  return _service;
1240
1677
  }
1678
+ /**
1679
+ * The field catalog: OpenAlex's valid-field lists as `scripts/generate-field-catalog.ts` writes
1680
+ * them to `field-catalog.json`, plus the select fields this server derives itself
1681
+ * (`author_list` on works), which no upstream list names and the generator would drop.
1682
+ */
1683
+ const FIELD_CATALOG = {
1684
+ ...fieldCatalog,
1685
+ works: { ...fieldCatalog.works, select: [...fieldCatalog.works.select, AUTHOR_LIST_FIELD] },
1686
+ };
1241
1687
  /**
1242
1688
  * Return the typed field catalog keyed by entity type → context → field list.
1243
1689
  * Only `filter` and `select` arrays are stored. `group_by` resolves to the `filter` key here;
@@ -1245,6 +1691,6 @@ export function getOpenAlexService() {
1245
1691
  * `*.search` operators, `from_*`/`to_*` range modifiers, and a per-type set from a live sweep).
1246
1692
  */
1247
1693
  export function getFieldCatalog() {
1248
- return fieldCatalog;
1694
+ return FIELD_CATALOG;
1249
1695
  }
1250
1696
  //# sourceMappingURL=openalex-service.js.map