@cyanheads/openalex-mcp-server 0.10.0 → 0.11.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 (64) hide show
  1. package/AGENTS.md +30 -15
  2. package/CLAUDE.md +30 -15
  3. package/Dockerfile +6 -0
  4. package/README.md +55 -116
  5. package/changelog/0.10.x/0.10.1.md +28 -0
  6. package/changelog/0.11.x/0.11.0.md +41 -0
  7. package/dist/config/server-config.d.ts.map +1 -1
  8. package/dist/config/server-config.js +1 -1
  9. package/dist/config/server-config.js.map +1 -1
  10. package/dist/index.js +2 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  13. package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
  14. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +1 -0
  15. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +10 -5
  17. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +6 -9
  19. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +43 -33
  21. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts +1 -0
  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 +25 -1
  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.map +1 -1
  27. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +44 -16
  28. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +22 -18
  30. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/search-entities.tool.js +194 -85
  32. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/escape-markdown.d.ts +26 -11
  34. package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/escape-markdown.js +95 -26
  36. package/dist/mcp-server/tools/escape-markdown.js.map +1 -1
  37. package/dist/mcp-server/tools/page-size.d.ts +38 -7
  38. package/dist/mcp-server/tools/page-size.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/page-size.js +37 -11
  40. package/dist/mcp-server/tools/page-size.js.map +1 -1
  41. package/dist/mcp-server/tools/record-page.d.ts +174 -0
  42. package/dist/mcp-server/tools/record-page.d.ts.map +1 -0
  43. package/dist/mcp-server/tools/record-page.js +392 -0
  44. package/dist/mcp-server/tools/record-page.js.map +1 -0
  45. package/dist/mcp-server/tools/render-entity-record.d.ts +3 -1
  46. package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/render-entity-record.js +7 -2
  48. package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
  49. package/dist/services/openalex/field-catalog.json +107 -0
  50. package/dist/services/openalex/openalex-service.d.ts +18 -7
  51. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  52. package/dist/services/openalex/openalex-service.js +84 -63
  53. package/dist/services/openalex/openalex-service.js.map +1 -1
  54. package/dist/services/openalex/types.d.ts +13 -2
  55. package/dist/services/openalex/types.d.ts.map +1 -1
  56. package/dist/services/openalex/types.js +28 -1
  57. package/dist/services/openalex/types.js.map +1 -1
  58. package/package.json +7 -6
  59. package/server.json +5 -5
  60. package/skills/openalex-guide/SKILL.md +77 -0
  61. package/dist/mcp-server/tools/response-budget.d.ts +0 -159
  62. package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
  63. package/dist/mcp-server/tools/response-budget.js +0 -509
  64. package/dist/mcp-server/tools/response-budget.js.map +0 -1
@@ -6,14 +6,25 @@ import { tool, z } from '@cyanheads/mcp-ts-core';
6
6
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { ATTR_MCP_TOOL_NAME, createCounter } from '@cyanheads/mcp-ts-core/utils';
8
8
  import { getServerConfig } from '../../../config/server-config.js';
9
- import { clampPageSize, pageSizeSchema } from '../page-size.js';
9
+ import { escapeMarkdown } from '../escape-markdown.js';
10
+ import { clampCount, countSchema, pageSizeSchema } from '../page-size.js';
11
+ import { buildRecordPage, buildSlicePage, formatRecordPage, windowsOutputSchema, } from '../record-page.js';
10
12
  import { renderBudgetTrailer } from '../render-budget.js';
11
- import { fitRecordsToBudget, fitSliceToBudget, formatRecordPage, joinNotices, measurePageFrame, omittedOutputSchema, overBudgetOutputSchema, windowsOutputSchema, } from '../response-budget.js';
12
13
  import { parseNativeFilters, quoteCallerText } from '../../../services/openalex/filter-values.js';
13
14
  import { getOpenAlexService, selectRecordKey } from '../../../services/openalex/openalex-service.js';
14
15
  import { ENTITY_TYPES } from '../../../services/openalex/types.js';
16
+ const PER_PAGE_CAP = 100;
17
+ const PER_PAGE_DEFAULT = 25;
15
18
  const SEMANTIC_PER_PAGE_CAP = 50;
16
- const SAMPLE_MAX = 100;
19
+ /** The most authorships OpenAlex stores per work, the longest array a slice needs to cover. */
20
+ const SLICE_LIMIT_CAP = 5000;
21
+ const SLICE_LIMIT_DEFAULT = 50;
22
+ /**
23
+ * The largest sample OpenAlex draws, and the most records of one it serves by page number:
24
+ * `page × per_page` above it is refused upstream even inside a smaller draw.
25
+ */
26
+ const SAMPLE_MAX = 10_000;
27
+ const SAMPLE_MAX_LABEL = SAMPLE_MAX.toLocaleString('en-US');
17
28
  /** What `meta.count` counts, in the rendered header. */
18
29
  const RESULT_NOUN = 'result(s)';
19
30
  const canonicalFilters = z.record(z.string(), z.string());
@@ -45,8 +56,8 @@ const filterStringConversions = createCounter('openalex.filter_string.conversion
45
56
  * answers an entity-by-ID URL identically whether or not search params ride along.
46
57
  *
47
58
  * Pagination (`per_page`, `cursor`, `page`) is deliberately absent: a singleton lookup is one
48
- * record by definition, and `per_page` always carries its schema default, so a caller's intent
49
- * there cannot be told apart from the absence of one. Naming it would be noise, not a warning.
59
+ * record by definition, so page size and position have nothing to apply to. Naming them would be
60
+ * noise, not a warning.
50
61
  */
51
62
  function searchOnlyParams(input) {
52
63
  const parts = [];
@@ -72,7 +83,8 @@ function searchOnlyParams(input) {
72
83
  * Echo the criteria that actually ran. An `id` lookup echoes `entity_type` and `id` only —
73
84
  * echoing a filter the singleton path never applied reads as confirmation that it did, which
74
85
  * is the one thing this field exists not to do. Caller text is JSON-encoded, so a line break in
75
- * it cannot start a heading or end the line the echo renders on.
86
+ * it cannot start a heading or end the line the echo renders on. Raw, as the `echo` field
87
+ * carries it; a notice repeating it escapes it for Markdown.
76
88
  */
77
89
  function buildSearchEcho(input) {
78
90
  const parts = [`entity_type=${input.entity_type}`];
@@ -84,16 +96,24 @@ function buildSearchEcho(input) {
84
96
  * Is this call continuing a paginated traversal rather than opening one? A `cursor` is the
85
97
  * keyword/exact signal — the schema rejects a blank one, so its presence is always a real
86
98
  * continuation rather than a mistake OpenAlex answers with page 1; a `page` past the first is
87
- * the semantic signal, and `page` only survives the handler's preconditions under semantic
88
- * mode. Only on a continuing call does a page of
89
- * zero results mean the traversal is finished — on a first call it means nothing matched, and
90
- * the two want opposite advice.
99
+ * the signal for semantic search and a seeded sample, the only listings `page` survives the
100
+ * handler's preconditions on. Only on a continuing call does a page of zero results mean the
101
+ * traversal is finished — on a first call it means nothing matched, and the two want opposite
102
+ * advice.
91
103
  */
92
104
  function isContinuationPage(input) {
93
105
  return input.cursor !== undefined || (input.page ?? 1) > 1;
94
106
  }
107
+ /**
108
+ * Whether records remain past this page of a `page`-walked listing that reaches `reachable`
109
+ * records — semantic candidates, or a sample's draw. A pointer to the next page appears only
110
+ * then: an exactly full last page has none to point at. (gh #126)
111
+ */
112
+ function hasNextPage(page, perPage, reachable) {
113
+ return page * perPage < reachable;
114
+ }
95
115
  export const searchEntitiesTool = tool('openalex_search_entities', {
96
- description: 'Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Filters take a record object; an unambiguous native string such as "issn:0920-8542" is also accepted when enabled and disclosed in `notice`. Native clauses split on commas outside paired double quotes and the first colon; duplicate keys and bare fragments are rejected. Any filter value sent unquoted must have paired double quotes; balanced search phrases remain valid. Supports keyword search with boolean operators, exact phrase matching, and AI semantic search. Use openalex_resolve_name to resolve names to IDs before filtering. Searches and ID lookups return a curated set of fields by default; pass `select` to override with specific fields, or `["*"]` for the full record. Responses cap at 64,000 bytes per surface unless the least a call can return is larger (`over_budget`); `omitted` and `windows` give the calls that continue a cut, and `slice` pages a long array.',
116
+ description: 'Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders, awards). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Filters take a record object; an unambiguous native string such as "issn:0920-8542" is also accepted when enabled and disclosed in `notice`. Native clauses split on commas outside paired double quotes and the first colon; duplicate keys and bare fragments are rejected. Any filter value sent unquoted must have paired double quotes; balanced search phrases remain valid. Supports keyword search with boolean operators, exact phrase matching, AI semantic search, and seeded random samples. Use openalex_resolve_name to resolve names to IDs before filtering. Searches and ID lookups return a curated set of fields by default; pass `select` to override with specific fields, or `["*"]` for the full record. A page holds `per_page` records and `next_cursor` continues it — semantic search and a seeded `sample` continue with `page` instead; an `id` lookup returns every selected array whole, and `slice` pages one array by count.',
97
117
  sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/search-entities.tool.ts',
98
118
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
99
119
  errors: [
@@ -121,9 +141,15 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
121
141
  {
122
142
  reason: 'semantic_per_page_cap',
123
143
  code: JsonRpcErrorCode.ValidationError,
124
- when: `A search (no \`id\`) set per_page above the semantic-search cap of ${SEMANTIC_PER_PAGE_CAP}.`,
144
+ when: `A semantic search (no \`id\`) set per_page above its cap of ${SEMANTIC_PER_PAGE_CAP} on a server with OPENALEX_CLAMP_PER_PAGE=false; with clamping on (the default), it returns ${SEMANTIC_PER_PAGE_CAP} and says so.`,
125
145
  recovery: `Reduce per_page to ${SEMANTIC_PER_PAGE_CAP} or less, or switch search_mode to keyword.`,
126
146
  },
147
+ {
148
+ reason: 'response_too_large',
149
+ code: JsonRpcErrorCode.ValidationError,
150
+ when: "The response's structuredContent or content[] text would exceed 8,000,000 bytes — a wide `select` on a large page, or a record with very long arrays. A page is never shortened to fit.",
151
+ recovery: 'Rerun with the per_page or slice.limit the error names when it names one, or narrow `select` to the fields you need; for a single oversized record, page its largest array with `slice`.',
152
+ },
127
153
  {
128
154
  reason: 'semantic_without_query',
129
155
  code: JsonRpcErrorCode.ValidationError,
@@ -139,20 +165,26 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
139
165
  {
140
166
  reason: 'page_without_semantic',
141
167
  code: JsonRpcErrorCode.ValidationError,
142
- when: 'A search supplied `page` under a search_mode other than "semantic".',
143
- recovery: 'Drop `page` and paginate with `cursor`, or set search_mode to "semantic" — the only mode `page` applies to.',
168
+ when: 'A search supplied `page` with neither search_mode "semantic" nor `sample`.',
169
+ recovery: 'Drop `page` and paginate with `cursor`, set search_mode to "semantic", or pass `sample` with a `seed` — the only searches `page` walks.',
144
170
  },
145
171
  {
146
172
  reason: 'sample_with_cursor',
147
173
  code: JsonRpcErrorCode.ValidationError,
148
174
  when: 'A search (no `id`) provided both `sample` and `cursor`.',
149
- recovery: 'Sampling returns a single page only; remove `cursor` or remove `sample`.',
175
+ recovery: 'A sample walks its draw with `page` and a `seed`, not `cursor`; remove `cursor`, or remove `sample`.',
176
+ },
177
+ {
178
+ reason: 'sample_page_without_seed',
179
+ code: JsonRpcErrorCode.ValidationError,
180
+ when: 'A search (no `id`) provided `page` with `sample` but no `seed`, which OpenAlex refuses: an unseeded sample redraws on every call, so its pages would overlap.',
181
+ recovery: 'Pass `seed` to page a sample — an unseeded sample redraws on every call — or drop `page`.',
150
182
  },
151
183
  {
152
- reason: 'sample_with_page',
184
+ reason: 'sample_page_too_deep',
153
185
  code: JsonRpcErrorCode.ValidationError,
154
- when: 'A search (no `id`) provided both `sample` and `page`.',
155
- recovery: 'Sampling returns a single page only; remove `page` or remove `sample`.',
186
+ when: `A sample page reaches past record ${SAMPLE_MAX_LABEL} (\`page\` × \`per_page\` above ${SAMPLE_MAX_LABEL}), the most of a sample OpenAlex serves by page number.`,
187
+ recovery: `Keep \`page\` × \`per_page\` at or under ${SAMPLE_MAX_LABEL}, with a per_page that divides ${SAMPLE_MAX_LABEL} — 25, 50, or 100 — so the last page ends on record ${SAMPLE_MAX_LABEL}; at per_page 100 that is page 100.`,
156
188
  },
157
189
  {
158
190
  reason: 'sample_with_semantic',
@@ -188,7 +220,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
188
220
  reason: 'id_type_mismatch',
189
221
  code: JsonRpcErrorCode.ValidationError,
190
222
  when: '`id` is a native OpenAlex ID whose letter names another entity type than `entity_type`, or a concept ID (C…), a type this server does not serve.',
191
- recovery: 'Pass the entity_type the ID letter names — W works, A authors, S sources, I institutions, T topics, P publishers, F funders. A concept ID (C…) has no entity_type here; search topics for the concept by name with openalex_resolve_name instead.',
223
+ recovery: 'Pass the entity_type the ID letter names — W works, A authors, S sources, I institutions, T topics, P publishers, F funders, G awards. A concept ID (C…) has no entity_type here; search topics for the concept by name with openalex_resolve_name instead.',
192
224
  thrownBy: 'service',
193
225
  },
194
226
  {
@@ -337,7 +369,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
337
369
  .string()
338
370
  .min(1)
339
371
  .optional()
340
- .describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807", or its OpenAlex record-page or API URL such as "https://openalex.org/works/W2741809807" or "https://api.openalex.org/works/W2741809807"), whose letter must name `entity_type` — W works, A authors, S sources, I institutions, T topics, P publishers, F funders; DOI ("10.1038/nature12373"; under entity_type "funders", a funder\'s Crossref Funder Registry DOI such as "10.13039/100000001"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361" — one ROR can name an institution, a funder, and a publisher, and `entity_type` picks which), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). Keywords are identified by slug rather than a native ID — pass either the slug ("groundwater") or the URL a search returns ("https://openalex.org/keywords/groundwater"). A PMCID is recognized too, bare ("PMC1234567") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `["*"]` for the complete record). An array too long for the response budget comes back as a window (`windows`); page the rest with `slice`. To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
372
+ .describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807", or its OpenAlex record-page or API URL such as "https://openalex.org/works/W2741809807" or "https://api.openalex.org/works/W2741809807"), whose letter must name `entity_type` — W works, A authors, S sources, I institutions, T topics, P publishers, F funders, G awards; DOI ("10.1038/nature12373"; under entity_type "funders", a funder\'s Crossref Funder Registry DOI such as "10.13039/100000001"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361" — one ROR can name an institution, a funder, and a publisher, and `entity_type` picks which), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). Keywords are identified by slug rather than a native ID — pass either the slug ("groundwater") or the URL a search returns ("https://openalex.org/keywords/groundwater"). A PMCID is recognized too, bare ("PMC1234567") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `["*"]` for the complete record). Every selected array returns whole; `slice` pages a long one by count. To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
341
373
  query: z
342
374
  .string()
343
375
  .min(1)
@@ -357,10 +389,10 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
357
389
  select: z
358
390
  .array(z.string())
359
391
  .optional()
360
- .describe('OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `["*"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. On sources, `publisher` returns `host_organization_name`: the publisher of a journal, the hosting institution of a repository, and null on most sources. On works, `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `["*"]` includes it. Invalid field names produce an error identifying the rejected field. Large projections may be cut to fit the response budget (`omitted`, `windows`). Not applied with `slice`. Example: ["doi", "author_list", "primary_topic"].'),
361
- per_page: pageSizeSchema(100)
362
- .default(25)
363
- .describe('Results per page. Default 25; positive integers above 100 clamp to 100 with a notice unless OPENALEX_CLAMP_PER_PAGE=false restores rejection. Semantic search caps at 50 — when search_mode="semantic", set per_page ≤ 50 (also subject to a 1 req/sec rate limit upstream). The semantic cap checks the requested size. An `id` lookup ignores page size, and `sample` overrides it. A budget-cut page returns fewer (`omitted`).'),
392
+ .describe('OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `["*"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. On sources, `publisher` returns `host_organization_name`: the publisher of a journal, the hosting institution of a repository, and null on most sources. On works, `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `["*"]` includes it. Invalid field names produce an error identifying the rejected field. A wide projection on a large page makes a large response, so name the fields you need. Not applied with `slice`. Example: ["doi", "author_list", "primary_topic"].'),
393
+ per_page: pageSizeSchema(PER_PAGE_CAP)
394
+ .optional()
395
+ .describe(`Results per page. Default ${PER_PAGE_DEFAULT}, or under \`sample\` the sample size up to ${PER_PAGE_CAP}, so a sample of ${PER_PAGE_CAP} or fewer returns whole in one call. A larger value returns at most ${PER_PAGE_CAP} and says so — at most ${SEMANTIC_PER_PAGE_CAP} under search_mode "semantic", which OpenAlex also limits to about 1 request per second. A server set to OPENALEX_CLAMP_PER_PAGE=false rejects a value over either cap instead. An \`id\` lookup ignores page size.`),
364
396
  cursor: z
365
397
  .string()
366
398
  .min(1)
@@ -371,18 +403,14 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
371
403
  .int()
372
404
  .min(1)
373
405
  .optional()
374
- .describe('Page number (1-based) for semantic search, the one mode that paginates with `page` instead of `cursor`. Semantic search ranks a query-dependent candidate set whose size `meta.count` reports, so the last reachable page is ceil(meta.count / per_page) — e.g. page 14 for a count of 70 with per_page=5. Passing it under any other search_mode is rejected.'),
375
- sample: z
376
- .number()
377
- .int()
378
- .min(1)
379
- .max(SAMPLE_MAX)
406
+ .describe(`Page number (1-based) for semantic search and for a seeded \`sample\`, the two searches that paginate with \`page\` instead of \`cursor\`. Semantic search ranks a query-dependent candidate set whose size \`meta.count\` reports, so the last reachable page is ceil(meta.count / per_page) — e.g. page 14 for a count of 70 with per_page=5. A sample draws min(sample, meta.count) records, so its last page is ceil(min(sample, meta.count) / per_page); paging a sample requires \`seed\`, and OpenAlex serves only its first ${SAMPLE_MAX_LABEL} records, so a page × per_page above ${SAMPLE_MAX_LABEL} is rejected. Passing it with any other search is rejected.`),
407
+ sample: countSchema(1, SAMPLE_MAX)
380
408
  .optional()
381
- .describe(`Return a random sample of this many entities matching the filters (1-${SAMPLE_MAX}). Single page only — neither \`cursor\` nor \`page\` pagination applies to sampling, and a search that passes either alongside it is rejected. Keyword and exact modes only: OpenAlex does not sample a semantic search, so \`sample\` with search_mode "semantic" is rejected. Cannot be combined with \`sort\` — a sample has no order, and a search passing both is rejected. Overrides \`per_page\`. Useful for unbiased exploration: spot-checking filter correctness, stratified review prompts, or generating exploration sets without bias toward most-cited.`),
409
+ .describe(`Return a random sample of this many entities matching the filters, up to ${SAMPLE_MAX_LABEL}; a larger value draws ${SAMPLE_MAX_LABEL} and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false). The sample comes back \`per_page\` records at a time — by default the whole sample up to ${PER_PAGE_CAP} — and a larger one is walked with \`page\` and a \`seed\`, which fixes the draw; without \`seed\` only its first page is returned. \`meta.count\` stays the population matching the filters. Keyword and exact modes only: OpenAlex does not sample a semantic search, so \`sample\` with search_mode "semantic" is rejected. Cannot be combined with \`cursor\` or \`sort\` — a sample has no order, and a search passing either is rejected. Useful for unbiased exploration: spot-checking filter correctness, stratified review prompts, or generating exploration sets without bias toward most-cited.`),
382
410
  seed: z
383
411
  .string()
384
412
  .optional()
385
- .describe('Deterministic seed for `sample`. Same seed + same filters = same results — pass when reproducibility matters. Has no effect without `sample`, and a search that passes it alone is rejected.'),
413
+ .describe('Deterministic seed for `sample`. Same seed + same filters = the same draw, page for page — required to walk a sample larger than one page with `page`, and worth passing whenever reproducibility matters. Has no effect without `sample`, and a search that passes it alone is rejected.'),
386
414
  slice: z
387
415
  .object({
388
416
  field: z
@@ -394,23 +422,26 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
394
422
  .min(0)
395
423
  .default(0)
396
424
  .describe('0-based index of the first element to return.'),
425
+ limit: countSchema(1, SLICE_LIMIT_CAP)
426
+ .default(SLICE_LIMIT_DEFAULT)
427
+ .describe(`Elements to return. Default ${SLICE_LIMIT_DEFAULT}; a larger value returns at most ${SLICE_LIMIT_CAP.toLocaleString('en-US')} and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false).`),
397
428
  })
398
429
  .optional()
399
- .describe('Page one array of the record at `id`: returns `id`, `display_name`, and `field` from `offset` onward, as many elements as fit the response budget (at least one), with the window and its `next` call in `windows`. Requires `id`; replaces `select`. An offset at or past the end returns an empty window. A 100-author list cap can be continued on the singleton; at 5,000 stored authors the byline remains possibly capped, but `next` is null once stored entries are exhausted.'),
430
+ .describe('Page one array of the record at `id`: returns `id`, `display_name`, and `limit` elements of `field` from `offset` — fewer only at the array end — with the window in `windows`, whose `next` call repeats `limit` from the following offset. Requires `id`; replaces `select`. An offset at or past the end returns an empty window. A 100-author list cap can be continued on the singleton; at 5,000 stored authors the byline remains possibly capped, but `next` is null once stored entries are exhausted.'),
400
431
  }),
401
432
  output: z.object({
402
433
  meta: z
403
434
  .object({
404
435
  count: z
405
436
  .number()
406
- .describe('Total results matching the query/filters. Under search_mode "semantic" it is instead the size of the ranked candidate set — the most results `page` can reach — not an exhaustive match total.'),
437
+ .describe('Total results matching the query/filters — under `sample` too, whose draw holds min(sample, count) records. Under search_mode "semantic" it is instead the size of the ranked candidate set — the most results `page` can reach — not an exhaustive match total.'),
407
438
  per_page: z
408
439
  .number()
409
- .describe('Page size OpenAlex echoed for this request — the applied per_page after clamping or sample override, not the number of records returned. A singleton reports 1. A short or exhausted page carries fewer records than this, as does a page the response budget cut (`omitted`).'),
440
+ .describe('Page size OpenAlex echoed for this request — the applied per_page after clamping or its default, not the number of records returned. A singleton reports 1. A short or exhausted page carries fewer records than this.'),
410
441
  next_cursor: z
411
442
  .string()
412
443
  .nullable()
413
- .describe('Cursor for next page. null if no more results. Continues after the full upstream page, `omitted` records included.'),
444
+ .describe('Cursor for the next page; pass it back unchanged as `cursor`. null when no further page exists — always under semantic search and `sample`, which continue with `page` — except on a final page that exactly fills `per_page`: OpenAlex still returns a cursor there, and it leads to an empty page.'),
414
445
  })
415
446
  .describe('Result metadata including pagination.'),
416
447
  results: z
@@ -418,7 +449,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
418
449
  .object({
419
450
  id: z
420
451
  .string()
421
- .describe('OpenAlex ID URL (e.g., "https://openalex.org/W2741809807"). `windows` and `omitted` name records by the bare ID ("W2741809807"); a keyword keeps its URL.'),
452
+ .describe('OpenAlex ID URL (e.g., "https://openalex.org/W2741809807"). `windows` names records by the bare ID ("W2741809807"); a keyword keeps its URL.'),
422
453
  display_name: z
423
454
  .string()
424
455
  .nullable()
@@ -426,10 +457,8 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
426
457
  })
427
458
  .loose()
428
459
  .describe('A single OpenAlex entity record. `id` is always present and `display_name` is always returned (though it may be null); additional fields vary by entity_type and `select`.'))
429
- .describe('OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Additional fields depend on entity_type and select. Records cut by the response budget are in `omitted`; partial or possibly capped arrays are in `windows`.'),
430
- omitted: omittedOutputSchema,
460
+ .describe('OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Additional fields depend on entity_type and select. Possibly capped author arrays and `slice` windows are in `windows`.'),
431
461
  windows: windowsOutputSchema,
432
- over_budget: overBudgetOutputSchema,
433
462
  }),
434
463
  // Agent-facing context for the success path — the query/filters as parsed, the
435
464
  // total match count, and recovery guidance for empty results. Populated via
@@ -442,7 +471,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
442
471
  notice: z
443
472
  .string()
444
473
  .optional()
445
- .describe('Guidance notice. Set when per_page was clamped (requested and applied sizes), a native filter string was converted, a first call has no matches, a continuation is exhausted, an id lookup ignores search criteria, semantic search reports a candidate total, the response budget cut records, windowed an array, or ran over, an author array is possibly capped (100 list entries or 5,000 stored singleton entries), a slice offset is past the end, or slice replaces select. Absent otherwise.'),
474
+ .describe('Guidance notice. Set when per_page, sample, or slice.limit was clamped (requested and applied sizes), a native filter string was converted, a first call has no matches, a continuation is exhausted, an id lookup ignores search criteria, semantic search reports a candidate total and whether a later `page` holds more, a sample draws more than one page (the next `page`, its last page, or that `seed` makes it pageable), an author array is possibly capped (100 list entries or 5,000 stored singleton entries), a slice offset is past the end, or slice replaces select. Absent otherwise.'),
446
475
  budget: z
447
476
  .object({
448
477
  costUsd: z
@@ -489,6 +518,9 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
489
518
  * to each other fails it on constraints it never met. The dropped-parameter notice below
490
519
  * reports them instead. The `id` predicate mirrors the service's own truthiness branch.
491
520
  */
521
+ // `per_page` as the caller set it, or its default: the whole sample up to a page, else 25.
522
+ const requestedPerPage = input.per_page ??
523
+ (input.sample !== undefined ? Math.min(input.sample, PER_PAGE_CAP) : PER_PAGE_DEFAULT);
492
524
  if (!input.id) {
493
525
  // Semantic search matches an embedding of the query text, so with no query there is
494
526
  // nothing to embed — the request silently degraded to a plain unfiltered listing whose
@@ -496,32 +528,33 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
496
528
  if (input.search_mode === 'semantic' && !input.query) {
497
529
  throw ctx.fail('semantic_without_query', 'Semantic search needs `query` text to embed — none was supplied.', { searchMode: input.search_mode });
498
530
  }
499
- if (input.search_mode === 'semantic' && input.per_page > SEMANTIC_PER_PAGE_CAP) {
500
- throw ctx.fail('semantic_per_page_cap', `Semantic search supports at most ${SEMANTIC_PER_PAGE_CAP} results per page. Reduce per_page or switch search_mode.`, { searchMode: input.search_mode, perPage: input.per_page, cap: SEMANTIC_PER_PAGE_CAP });
531
+ // With clamping on, an over-cap semantic page size clamps below instead. A semantic sample
532
+ // is refused below as `sample_with_semantic` whatever its page size, which the sample sets.
533
+ if (input.search_mode === 'semantic' &&
534
+ input.sample === undefined &&
535
+ requestedPerPage > SEMANTIC_PER_PAGE_CAP &&
536
+ !getServerConfig().clampPerPage) {
537
+ throw ctx.fail('semantic_per_page_cap', `Semantic search supports at most ${SEMANTIC_PER_PAGE_CAP} results per page. Reduce per_page or switch search_mode.`, { searchMode: input.search_mode, perPage: requestedPerPage, cap: SEMANTIC_PER_PAGE_CAP });
501
538
  }
502
539
  // OpenAlex answers `search.semantic` plus a cursor with a 400 ("Cursor pagination is not
503
540
  // supported with semantic search"), so forwarding it spends a round trip to learn that.
504
541
  if (input.search_mode === 'semantic' && input.cursor !== undefined) {
505
542
  throw ctx.fail('semantic_with_cursor', 'Semantic search does not accept `cursor` — walk its candidates with `page` (1-based) instead.', { searchMode: input.search_mode, cursor: input.cursor });
506
543
  }
507
- // The inverse: `page` is the semantic-only knob, and OpenAlex ignores it on a cursor
508
- // traversal — silently returning page 1 again rather than the page that was asked for.
509
- if (input.page !== undefined && input.search_mode !== 'semantic') {
510
- throw ctx.fail('page_without_semantic', `\`page\` applies to semantic search only — search_mode is "${input.search_mode}", which paginates with \`cursor\`.`, { searchMode: input.search_mode, page: input.page });
544
+ // The inverse: `page` walks semantic candidates and samples only, and OpenAlex ignores it on
545
+ // a cursor traversal — silently returning page 1 again rather than the page asked for.
546
+ if (input.page !== undefined &&
547
+ input.search_mode !== 'semantic' &&
548
+ input.sample === undefined) {
549
+ throw ctx.fail('page_without_semantic', `\`page\` applies to semantic search and samples only — search_mode is "${input.search_mode}" with no \`sample\`, which paginates with \`cursor\`.`, { searchMode: input.search_mode, page: input.page });
511
550
  }
512
551
  if (input.sample !== undefined && input.cursor !== undefined) {
513
- throw ctx.fail('sample_with_cursor', 'Sampling returns one page only — `sample` cannot be combined with `cursor` pagination.', { sample: input.sample, cursor: input.cursor });
514
- }
515
- // Sampling pins `per_page` to `sample` and returns that one page, so there is no second
516
- // page for `page` to address — the same exclusion `cursor` has above. Reachable under
517
- // semantic mode alone; every other mode rejects `page` at the check before this one.
518
- if (input.sample !== undefined && input.page !== undefined) {
519
- throw ctx.fail('sample_with_page', 'Sampling returns one page only — `sample` cannot be combined with `page` pagination.', { sample: input.sample, page: input.page });
552
+ throw ctx.fail('sample_with_cursor', '`sample` cannot be combined with `cursor` — a seeded sample pages with `page`.', { sample: input.sample, cursor: input.cursor });
520
553
  }
521
554
  // OpenAlex ignores `sample` and `seed` under `search.semantic` and returns the ranked page,
522
555
  // so forwarding the pair would label a relevance ranking as a random sample. The population
523
556
  // request would also carry `cursor=*` beside `search.semantic`, which upstream rejects.
524
- // Checked after `sample_with_page`, which is reachable under semantic mode alone.
557
+ // Checked ahead of the sample-paging checks below: a semantic sample is wrong at any page.
525
558
  if (input.sample !== undefined && input.search_mode === 'semantic') {
526
559
  throw ctx.fail('sample_with_semantic', 'Semantic search does not sample — OpenAlex returns the same ranked candidates under every seed.', { sample: input.sample, searchMode: input.search_mode });
527
560
  }
@@ -533,10 +566,49 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
533
566
  if (input.seed !== undefined && input.sample === undefined) {
534
567
  throw ctx.fail('seed_without_sample', '`seed` is only meaningful with `sample` — pass `sample` to enable random sampling.', { seed: input.seed });
535
568
  }
569
+ // OpenAlex refuses `page` on an unseeded sample (HTTP 400): each call redraws it.
570
+ if (input.sample !== undefined && input.page !== undefined && input.seed === undefined) {
571
+ throw ctx.fail('sample_page_without_seed', '`page` walks a sample only with `seed` — an unseeded sample redraws on every call, so its pages would overlap.', { sample: input.sample, page: input.page });
572
+ }
573
+ // OpenAlex serves the first 10,000 records of a sample by page number and answers a page
574
+ // past them with a 400, even inside a smaller draw. Measured at the per_page the call
575
+ // will send, which a clamp can only lower.
576
+ const sentPerPage = Math.min(requestedPerPage, PER_PAGE_CAP);
577
+ if (input.sample !== undefined &&
578
+ input.page !== undefined &&
579
+ input.page * sentPerPage > SAMPLE_MAX) {
580
+ const last = input.page * sentPerPage;
581
+ throw ctx.fail('sample_page_too_deep', `Page ${input.page} at per_page ${sentPerPage} reaches record ${last.toLocaleString('en-US')}, past the first ${SAMPLE_MAX_LABEL} records OpenAlex serves from a sample.`, { sample: input.sample, page: input.page, perPage: sentPerPage, maxRecords: SAMPLE_MAX });
582
+ }
536
583
  }
537
- const pageSize = input.id || input.sample !== undefined
538
- ? { applied: input.per_page, notice: undefined }
539
- : clampPageSize(input.per_page, 100, 'openalex_search_entities', ctx);
584
+ const semantic = input.search_mode === 'semantic';
585
+ // An `id` lookup draws no sample, so its `sample` is neither clamped nor disclosed.
586
+ const sample = input.id || input.sample === undefined
587
+ ? undefined
588
+ : clampCount({
589
+ input: 'sample',
590
+ requested: input.sample,
591
+ maximum: SAMPLE_MAX,
592
+ tool: 'openalex_search_entities',
593
+ }, ctx);
594
+ const appliedSample = sample?.applied ?? input.sample;
595
+ const pageSize = input.id
596
+ ? { applied: requestedPerPage, notice: undefined }
597
+ : clampCount({
598
+ input: 'per_page',
599
+ requested: requestedPerPage,
600
+ maximum: semantic ? SEMANTIC_PER_PAGE_CAP : PER_PAGE_CAP,
601
+ ...(semantic && { maximumLabel: 'semantic-search maximum' }),
602
+ tool: 'openalex_search_entities',
603
+ }, ctx);
604
+ const sliceLimit = slice
605
+ ? clampCount({
606
+ input: 'slice.limit',
607
+ requested: slice.limit,
608
+ maximum: SLICE_LIMIT_CAP,
609
+ tool: 'openalex_search_entities',
610
+ }, ctx)
611
+ : undefined;
540
612
  const service = getOpenAlexService();
541
613
  const result = await service.search({
542
614
  entityType: input.entity_type,
@@ -549,7 +621,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
549
621
  perPage: pageSize.applied,
550
622
  cursor: input.cursor,
551
623
  page: input.page,
552
- sample: input.sample,
624
+ sample: appliedSample,
553
625
  seed: input.seed,
554
626
  }, ctx);
555
627
  ctx.log.info('Search completed', {
@@ -559,11 +631,15 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
559
631
  resultCount: result.results.length,
560
632
  totalCount: result.meta.count,
561
633
  });
562
- const echo = buildSearchEcho(input);
634
+ const echo = buildSearchEcho({ ...input, sample: appliedSample });
563
635
  ctx.enrich({ echo, totalCount: result.meta.count });
564
636
  const notices = [];
637
+ if (sample?.notice)
638
+ notices.push(sample.notice);
565
639
  if (pageSize.notice)
566
640
  notices.push(pageSize.notice);
641
+ if (sliceLimit?.notice)
642
+ notices.push(sliceLimit.notice);
567
643
  if (normalized?.converted) {
568
644
  notices.push('Converted native filter string to a filters record object.');
569
645
  }
@@ -576,33 +652,64 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
576
652
  notices.push(`\`id\` takes precedence — ${ignored.join(', ')} ${verb} not applied, and the record below is the entity at that ID regardless of ${pronoun}. Drop \`id\` to run ${pronoun} as a search.`);
577
653
  }
578
654
  else if (result.results.length === 0) {
655
+ // A notice is authored Markdown, so the caller text the echo repeats is escaped in it.
656
+ const criteria = escapeMarkdown(echo);
579
657
  notices.push(isContinuationPage(input)
580
- ? `Pagination exhausted for ${echo} — the previous page was the last, so this one came back empty. Stop paging rather than broadening the search.`
581
- : `No matches for ${echo}. Try broadening the query, removing filters, or switching search_mode.`);
658
+ ? `Pagination exhausted for ${criteria} — the previous page was the last, so this one came back empty. Stop paging rather than broadening the search.`
659
+ : `No matches for ${criteria}. Try broadening the query, removing filters, or switching search_mode.`);
582
660
  }
661
+ /**
662
+ * Semantic search and samples carry no `next_cursor`, so these notices are their only
663
+ * continuation signal. The empty page past the last carries the exhausted advice above alone.
664
+ */
665
+ const page = input.page ?? 1;
666
+ const exhausted = result.results.length === 0 && isContinuationPage(input);
583
667
  // `meta.count` reports the ranked candidate set under semantic search, not the population
584
668
  // matching the query. Its size is query-dependent (OpenAlex serves 70 for some queries,
585
669
  // 50 for others), so the notice states the count it got rather than a fixed ceiling.
586
- if (!input.id && input.search_mode === 'semantic') {
587
- notices.push(`Semantic search ranks a candidate set rather than every match, so \`meta.count\` (${result.meta.count}) is the number of candidates \`page\` can reach, not the total number of matching records. Reach the rest of the candidates with \`page\`.`);
670
+ if (!input.id && semantic && !exhausted) {
671
+ const continuation = hasNextPage(page, pageSize.applied, result.meta.count)
672
+ ? ` Continue with \`page: ${page + 1}\` for the rest of the candidates.`
673
+ : result.results.length > 0
674
+ ? ' The candidate set ends on this page.'
675
+ : '';
676
+ notices.push(`Semantic search ranks a candidate set rather than every match, so \`meta.count\` (${result.meta.count}) is the number of candidates \`page\` can reach, not the total number of matching records.${continuation}`);
677
+ }
678
+ // `meta.count` stays the population under `sample`; the draw is the smaller of the two. A
679
+ // draw that fits one page needs no notice — the page holds all of it.
680
+ const drawn = sample ? Math.min(sample.applied, result.meta.count) : 0;
681
+ if (result.results.length > 0 && drawn > pageSize.applied) {
682
+ const size = `${drawn.toLocaleString('en-US')} records`;
683
+ // OpenAlex serves no sample page reaching past record 10,000, so at a per_page that does not
684
+ // divide 10,000 the records after its last whole page are out of reach at that size.
685
+ const reachable = Math.min(drawn, Math.floor(SAMPLE_MAX / pageSize.applied) * pageSize.applied);
686
+ notices.push(input.seed === undefined
687
+ ? `This sample draws ${size}, more than one page of ${pageSize.applied}, so only its first page is returned. Pass a \`seed\` to fix the draw and walk it with \`page\` — an unseeded sample redraws on every call.`
688
+ : hasNextPage(page, pageSize.applied, reachable)
689
+ ? `This seeded sample draws ${size}, ${pageSize.applied} per page; continue with \`page: ${page + 1}\` and the same \`seed\`.`
690
+ : reachable < drawn
691
+ ? `This seeded sample draws ${size}, ${pageSize.applied} per page, and this is the last page OpenAlex serves at that size. Records ${(reachable + 1).toLocaleString('en-US')}–${drawn.toLocaleString('en-US')} are reachable only at a per_page that divides ${SAMPLE_MAX_LABEL}, such as 25, 50, or 100.`
692
+ : `This seeded sample draws ${size}, ${pageSize.applied} per page; the draw ends on this page.`);
588
693
  }
589
694
  if (slice && input.select) {
590
695
  notices.push('`slice` returns only `id`, `display_name`, and the sliced field, so `select` was not applied.');
591
696
  }
592
- const meta = {
593
- count: result.meta.count,
594
- per_page: result.meta.per_page,
595
- next_cursor: result.meta.next_cursor,
596
- };
597
- const measureFrame = (disclosure, budgetNotice) => measurePageFrame({ meta, results: [], ...disclosure }, RESULT_NOUN, {
697
+ const context = {
698
+ entityType: input.entity_type,
699
+ meta: {
700
+ count: result.meta.count,
701
+ per_page: result.meta.per_page,
702
+ next_cursor: result.meta.next_cursor,
703
+ },
704
+ noun: RESULT_NOUN,
598
705
  echo,
599
706
  totalCount: result.meta.count,
600
707
  totalLabel: 'Total',
601
- notice: joinNotices([...notices, budgetNotice]),
602
- });
603
- let page;
708
+ notices,
709
+ };
710
+ let outcome;
604
711
  // A slice without an `id` was refused as `slice_without_id`, so `input.id` only narrows here.
605
- if (slice && input.id) {
712
+ if (slice && sliceLimit && input.id) {
606
713
  // The one-field projection lands under its record key — `authors` arrives as `authorships`,
607
714
  // and `author_list` as itself.
608
715
  const field = selectRecordKey(input.entity_type, slice.field);
@@ -614,27 +721,29 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
614
721
  if (slice.offset > items.length) {
615
722
  notices.push(`slice.offset ${slice.offset} is past the end of \`${field}\`, which holds ${items.length} element(s), so nothing follows.`);
616
723
  }
617
- page = fitSliceToBudget({
724
+ outcome = buildSlicePage({
725
+ ...context,
618
726
  record,
619
727
  field,
620
728
  offset: slice.offset,
621
- entityType: input.entity_type,
622
- measureFrame,
729
+ limit: sliceLimit.applied,
623
730
  });
624
731
  }
625
732
  else {
626
- page = fitRecordsToBudget({
733
+ outcome = buildRecordPage({
734
+ ...context,
627
735
  records: result.results,
628
- entityType: input.entity_type,
629
736
  path: input.id ? 'lookup' : 'list',
630
- select: input.select,
631
- measureFrame,
737
+ continuation: semantic || sample ? 'page' : 'cursor',
738
+ page,
632
739
  });
633
740
  }
634
- const notice = joinNotices([...notices, page.notice]);
635
- if (notice)
636
- ctx.enrich.notice(notice);
637
- return { meta, results: page.results, ...page.disclosure };
741
+ if (outcome.kind === 'too_large') {
742
+ throw ctx.fail('response_too_large', outcome.message, outcome.data);
743
+ }
744
+ if (outcome.notice)
745
+ ctx.enrich.notice(outcome.notice);
746
+ return outcome.page;
638
747
  },
639
748
  format: (result) => formatRecordPage(result, RESULT_NOUN),
640
749
  });