@cyanheads/openalex-mcp-server 0.7.13 → 0.7.15

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 (36) hide show
  1. package/AGENTS.md +25 -10
  2. package/CLAUDE.md +25 -10
  3. package/README.md +167 -88
  4. package/changelog/0.7.x/0.7.14.md +28 -0
  5. package/changelog/0.7.x/0.7.15.md +33 -0
  6. package/changelog/template.md +7 -7
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
  10. package/dist/mcp-server/prompts/definitions/literature-review.prompt.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +19 -0
  12. package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +32 -3
  14. package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +12 -1
  16. package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/citation-graph.tool.js +55 -33
  18. package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +11 -0
  20. package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/resolve-name.tool.js +13 -1
  22. package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +40 -0
  24. package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/search-entities.tool.js +132 -20
  26. package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
  27. package/dist/services/openalex/openalex-service.d.ts +16 -0
  28. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  29. package/dist/services/openalex/openalex-service.js +105 -29
  30. package/dist/services/openalex/openalex-service.js.map +1 -1
  31. package/dist/services/openalex/types.d.ts +11 -0
  32. package/dist/services/openalex/types.d.ts.map +1 -1
  33. package/dist/services/openalex/types.js +7 -0
  34. package/dist/services/openalex/types.js.map +1 -1
  35. package/package.json +7 -7
  36. package/server.json +3 -3
@@ -19,6 +19,7 @@ export const resolveNameTool = tool('openalex_resolve_name', {
19
19
  when: 'OpenAlex throttled the autocomplete request for exceeding its per-second ceiling (HTTP 429).',
20
20
  retryable: true,
21
21
  recovery: 'Wait several seconds and retry; consider lowering request frequency for this caller.',
22
+ thrownBy: 'service',
22
23
  },
23
24
  {
24
25
  reason: 'upstream_budget_exhausted',
@@ -26,6 +27,7 @@ export const resolveNameTool = tool('openalex_resolve_name', {
26
27
  when: 'The OpenAlex daily usage budget is spent (HTTP 429).',
27
28
  retryable: false,
28
29
  recovery: 'The daily budget refills at midnight UTC — retrying sooner will not succeed. Set OPENALEX_API_KEY to a free key (https://openalex.org/settings/api) for a larger daily budget than anonymous access, or wait for the reset.',
30
+ thrownBy: 'service',
29
31
  },
30
32
  {
31
33
  reason: 'upstream_timeout',
@@ -33,6 +35,7 @@ export const resolveNameTool = tool('openalex_resolve_name', {
33
35
  when: 'OpenAlex did not respond within the request deadline.',
34
36
  retryable: true,
35
37
  recovery: 'Retry after a short delay; if timeouts persist, narrow the request with tighter filters to reduce upstream load.',
38
+ thrownBy: 'service',
36
39
  },
37
40
  {
38
41
  reason: 'upstream_unavailable',
@@ -40,50 +43,59 @@ export const resolveNameTool = tool('openalex_resolve_name', {
40
43
  when: 'OpenAlex was unreachable or unusable — HTTP 503, a connection failure, or a body that was empty, HTML, or unparseable JSON.',
41
44
  retryable: true,
42
45
  recovery: 'Wait and retry; check https://openalex.org for service status if the outage persists.',
46
+ thrownBy: 'service',
43
47
  },
44
48
  {
45
49
  reason: 'upstream_unauthorized',
46
50
  code: JsonRpcErrorCode.Unauthorized,
47
51
  when: 'OpenAlex rejected the API key (HTTP 401).',
48
52
  recovery: 'Check that OPENALEX_API_KEY is set to a valid OpenAlex account API key (free from https://openalex.org/settings/api).',
53
+ thrownBy: 'service',
49
54
  },
50
55
  {
51
56
  reason: 'upstream_forbidden',
52
57
  code: JsonRpcErrorCode.Forbidden,
53
58
  when: 'OpenAlex denied access to autocomplete (HTTP 403).',
54
59
  recovery: 'Confirm the API key has access to autocomplete, then retry the request.',
60
+ thrownBy: 'service',
55
61
  },
56
62
  {
57
63
  reason: 'comma_in_filter_value',
58
64
  code: JsonRpcErrorCode.InvalidParams,
59
65
  when: 'A `filters` value contains a comma, which collides with the OpenAlex filter separator.',
60
66
  recovery: 'Use `|` for OR within a filter value (e.g. "2020|2021"), or move a free-text phrase containing commas into the `query` parameter.',
67
+ thrownBy: 'service',
61
68
  },
62
69
  {
63
70
  reason: 'upstream_invalid_params',
64
71
  code: JsonRpcErrorCode.InvalidParams,
65
72
  when: 'OpenAlex rejected an invalid filter field name on the autocomplete query (HTTP 400).',
66
73
  recovery: 'The upstream message names the rejected field and suggests close matches. Use openalex_describe_fields(entity_type, "filter") to browse valid filter fields, or drop `filters` entirely.',
74
+ thrownBy: 'service',
67
75
  },
68
76
  {
69
77
  reason: 'upstream_invalid_id_value',
70
78
  code: JsonRpcErrorCode.InvalidParams,
71
79
  when: 'A `filters` entry expecting an entity ID received a value that is not an OpenAlex ID — usually a name (HTTP 400).',
72
80
  recovery: 'Resolve that name to an OpenAlex ID first — run this tool without the ID-valued filter, take the `id` from a match, then re-run with the ID.',
81
+ thrownBy: 'service',
73
82
  },
74
83
  {
75
84
  reason: 'upstream_invalid_params_other',
76
85
  code: JsonRpcErrorCode.InvalidParams,
77
86
  when: 'OpenAlex rejected the autocomplete request (HTTP 400) for a reason other than an invalid field name.',
78
87
  recovery: 'Read the upstream message in the error above and adjust the request — trim the query, check filter value formats, and ensure entity_type is a supported value.',
88
+ thrownBy: 'service',
79
89
  },
80
90
  {
81
91
  reason: 'upstream_validation_failed',
82
92
  code: JsonRpcErrorCode.ValidationError,
83
93
  when: 'OpenAlex rejected the autocomplete request as semantically invalid (HTTP 422).',
84
94
  recovery: 'Read the upstream message for the specific field, then adjust the request to satisfy validation.',
95
+ thrownBy: 'service',
85
96
  },
86
97
  ],
98
+ inputAliases: { name: 'query', filter: 'filters' },
87
99
  input: z.object({
88
100
  entity_type: z
89
101
  .enum(ENTITY_TYPES)
@@ -92,7 +104,7 @@ export const resolveNameTool = tool('openalex_resolve_name', {
92
104
  query: z
93
105
  .string()
94
106
  .min(1)
95
- .describe('Name or partial name to resolve. Also accepts an identifier, bare or in URL form — OpenAlex ID ("W2741809807", "F4320332161"), DOI ("10.1038/nature12373"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361"), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678") — which resolves straight to that one record instead of running a name search. A PMCID ("PMC1234567" or a PubMed Central URL) is recognized but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead.'),
107
+ .describe('Name or partial name to resolve. Also accepts an identifier, bare or in URL form — OpenAlex ID ("W2741809807", "F4320332161"), DOI ("10.1038/nature12373"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361"), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678") — which resolves straight to that one record instead of running a name search. A keyword URL ("https://openalex.org/keywords/groundwater") resolves the same way; a bare keyword slug reads as a name and runs a name search, which finds it too. A PMCID ("PMC1234567" or a PubMed Central URL) is recognized but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead.'),
96
108
  filters: z
97
109
  .record(z.string(), z.string())
98
110
  .optional()
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-name.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/resolve-name.tool.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"resolve-name.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/resolve-name.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,sBAAsB,GACvB,MAAM,yCAAyC,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAE5D,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC,uBAAuB,EAAE;IAC3D,WAAW,EACT,wgBAAwgB;IAC1gB,SAAS,EACP,kHAAkH;IACpH,WAAW,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;IAC9E,MAAM,EAAE;QACN;YACE,MAAM,EAAE,cAAc;YACtB,IAAI,EAAE,gBAAgB,CAAC,WAAW;YAClC,IAAI,EAAE,8FAA8F;YACpG,SAAS,EAAE,IAAI;YACf,QAAQ,EACN,sFAAsF;YACxF,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,2BAA2B;YACnC,IAAI,EAAE,gBAAgB,CAAC,WAAW;YAClC,IAAI,EAAE,sDAAsD;YAC5D,SAAS,EAAE,KAAK;YAChB,QAAQ,EACN,6NAA6N;YAC/N,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,kBAAkB;YAC1B,IAAI,EAAE,gBAAgB,CAAC,OAAO;YAC9B,IAAI,EAAE,uDAAuD;YAC7D,SAAS,EAAE,IAAI;YACf,QAAQ,EACN,kHAAkH;YACpH,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,sBAAsB;YAC9B,IAAI,EAAE,gBAAgB,CAAC,kBAAkB;YACzC,IAAI,EAAE,6HAA6H;YACnI,SAAS,EAAE,IAAI;YACf,QAAQ,EACN,uFAAuF;YACzF,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,uBAAuB;YAC/B,IAAI,EAAE,gBAAgB,CAAC,YAAY;YACnC,IAAI,EAAE,2CAA2C;YACjD,QAAQ,EACN,uHAAuH;YACzH,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,oBAAoB;YAC5B,IAAI,EAAE,gBAAgB,CAAC,SAAS;YAChC,IAAI,EAAE,oDAAoD;YAC1D,QAAQ,EAAE,yEAAyE;YACnF,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,uBAAuB;YAC/B,IAAI,EAAE,gBAAgB,CAAC,aAAa;YACpC,IAAI,EAAE,wFAAwF;YAC9F,QAAQ,EACN,mIAAmI;YACrI,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,yBAAyB;YACjC,IAAI,EAAE,gBAAgB,CAAC,aAAa;YACpC,IAAI,EAAE,sFAAsF;YAC5F,QAAQ,EACN,0LAA0L;YAC5L,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,2BAA2B;YACnC,IAAI,EAAE,gBAAgB,CAAC,aAAa;YACpC,IAAI,EAAE,mHAAmH;YACzH,QAAQ,EACN,8IAA8I;YAChJ,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,+BAA+B;YACvC,IAAI,EAAE,gBAAgB,CAAC,aAAa;YACpC,IAAI,EAAE,sGAAsG;YAC5G,QAAQ,EACN,gKAAgK;YAClK,QAAQ,EAAE,SAAS;SACpB;QACD;YACE,MAAM,EAAE,4BAA4B;YACpC,IAAI,EAAE,gBAAgB,CAAC,eAAe;YACtC,IAAI,EAAE,gFAAgF;YACtF,QAAQ,EACN,kGAAkG;YACpG,QAAQ,EAAE,SAAS;SACpB;KACF;IACD,YAAY,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE;IAClD,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC;QACd,WAAW,EAAE,CAAC;aACX,IAAI,CAAC,YAAY,CAAC;aAClB,QAAQ,EAAE;aACV,QAAQ,CACP,qLAAqL,CACtL;QACH,KAAK,EAAE,CAAC;aACL,MAAM,EAAE;aACR,GAAG,CAAC,CAAC,CAAC;aACN,QAAQ,CACP,isBAAisB,CAClsB;QACH,OAAO,EAAE,CAAC;aACP,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;aAC9B,QAAQ,EAAE;aACV,QAAQ,CACP,8LAA8L,CAC/L;KACJ,CAAC;IACF,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;QACf,OAAO,EAAE,CAAC;aACP,KAAK,CACJ,CAAC;aACE,MAAM,CAAC;YACN,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,cAAc,CAAC;YACvC,WAAW,EAAE,CAAC;iBACX,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,gDAAgD,CAAC;YAC7D,YAAY,EAAE,CAAC;iBACZ,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CACP,oLAAoL,CACrL;YACH,WAAW,EAAE,CAAC;iBACX,MAAM,EAAE;iBACR,QAAQ,CACP,6FAA6F,CAC9F;YACH,cAAc,EAAE,CAAC;iBACd,MAAM,EAAE;iBACR,QAAQ,CAAC,0DAA0D,CAAC;YACvE,WAAW,EAAE,CAAC;iBACX,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,8CAA8C,CAAC;YAC3D,IAAI,EAAE,CAAC;iBACJ,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CACP,4OAA4O,CAC7O;SACJ,CAAC;aACD,QAAQ,CACP,wGAAwG,CACzG,CACJ;aACA,QAAQ,CAAC,iCAAiC,CAAC;KAC/C,CAAC;IAEF,qFAAqF;IACrF,2FAA2F;IAC3F,uFAAuF;IACvF,qFAAqF;IACrF,UAAU,EAAE;QACV,MAAM,EAAE,CAAC;aACN,MAAM,EAAE;aACR,QAAQ,EAAE;aACV,QAAQ,CACP,gMAAgM,CACjM;QACH,MAAM,EAAE,CAAC;aACN,MAAM,CAAC;YACN,OAAO,EAAE,CAAC;iBACP,MAAM,EAAE;iBACR,QAAQ,CACP,gKAAgK,CACjK;YACH,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;YACzF,eAAe,EAAE,CAAC;iBACf,MAAM,EAAE;iBACR,QAAQ,CAAC,wDAAwD,CAAC;YACrE,mBAAmB,EAAE,CAAC;iBACnB,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CACP,mQAAmQ,CACpQ;SACJ,CAAC;aACD,QAAQ,EAAE;aACV,QAAQ,CACP,oNAAoN,CACrN;KACJ;IAED,iBAAiB,EAAE;QACjB,MAAM,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE;KACxC;IAED,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG;QACtB,MAAM,OAAO,GAAG,kBAAkB,EAAE,CAAC;QACrC,wFAAwF;QACxF,0FAA0F;QAC1F,uFAAuF;QACvF,MAAM,UAAU,GAAG,eAAe,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAEhD,MAAM,MAAM,GAAG,UAAU;YACvB,CAAC,CAAC,MAAM,OAAO,CAAC,iBAAiB,CAAC,UAAU,EAAE,GAAG,CAAC;YAClD,CAAC,CAAC,MAAM,OAAO,CAAC,YAAY,CACxB;gBACE,UAAU,EAAE,KAAK,CAAC,WAAW;gBAC7B,KAAK,EAAE,KAAK,CAAC,KAAK;gBAClB,OAAO,EAAE,KAAK,CAAC,OAAO;aACvB,EACD,GAAG,CACJ,CAAC;QAEN,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,EAAE;YAC5B,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,UAAU,EAAE,UAAU,EAAE,UAAU,IAAI,KAAK,CAAC,WAAW,IAAI,KAAK;YAChE,WAAW,EAAE,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,cAAc;YAC5D,UAAU,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM;SAClC,CAAC,CAAC;QAEH,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,MAAM,GACV,UAAU,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;YACrF,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YACvD,MAAM,OAAO,GAAa,EAAE,CAAC;YAC7B,IAAI,KAAK,CAAC,WAAW,IAAI,KAAK,CAAC,WAAW,KAAK,UAAU,CAAC,UAAU,EAAE,CAAC;gBACrE,OAAO,CAAC,IAAI,CAAC,gBAAgB,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC;YACrD,CAAC;YACD,IAAI,KAAK,CAAC,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC;gBAAE,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACpF,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,CAAC,IAAI,CACV,aAAa,KAAK,CAAC,KAAK,QAAQ,MAAM,wBAAwB,MAAM,iBAAiB,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAC5H,CAAC;YACJ,CAAC;YACD,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAChC,oFAAoF;gBACpF,kFAAkF;gBAClF,+DAA+D;gBAC/D,OAAO,CAAC,IAAI,CACV,UAAU,CAAC,MAAM,KAAK,OAAO;oBAC3B,CAAC,CAAC,MAAM,MAAM,2BAA2B,KAAK,CAAC,KAAK,MAAM,sBAAsB,EAAE;oBAClF,CAAC,CAAC,MAAM,MAAM,oBAAoB,MAAM,KAAK,KAAK,CAAC,KAAK,+GAA+G,CAC1K,CAAC;YACJ,CAAC;QACH,CAAC;aAAM,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvC,OAAO,CAAC,IAAI,CACV,mBAAmB,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,2FAA2F,CACpL,CAAC;QACJ,CAAC;QACD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;QAE7D,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE;QACjB,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAChC,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,CAAC,CAAC;QACvD,CAAC;QACD,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YAC/B,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,YAAY,IAAI,YAAY,OAAO,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC;YACvE,MAAM,OAAO,GAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACjC,IAAI,CAAC,CAAC,WAAW;gBAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;YAC/C,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,cAAc,YAAY,CAAC,CAAC;YAC9C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,KAAK,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,WAAW,QAAQ,CAAC,CAAC;YAC9E,IAAI,CAAC,CAAC,IAAI;gBAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;YACjC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;YAChC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjB,CAAC;QACD,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC9D,CAAC;CACF,CAAC,CAAC"}
@@ -27,6 +27,7 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
27
27
  select: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
28
  per_page: z.ZodDefault<z.ZodNumber>;
29
29
  cursor: z.ZodOptional<z.ZodString>;
30
+ page: z.ZodOptional<z.ZodNumber>;
30
31
  sample: z.ZodOptional<z.ZodNumber>;
31
32
  seed: z.ZodOptional<z.ZodString>;
32
33
  }, z.core.$strip>, z.ZodObject<{
@@ -44,11 +45,31 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
44
45
  readonly code: JsonRpcErrorCode.ValidationError;
45
46
  readonly when: "A search (no `id`) set per_page above the semantic-search cap of 50.";
46
47
  readonly recovery: "Reduce per_page to 50 or less, or switch search_mode to keyword.";
48
+ }, {
49
+ readonly reason: "semantic_without_query";
50
+ readonly code: JsonRpcErrorCode.ValidationError;
51
+ readonly when: "A search set search_mode to \"semantic\" without supplying the `query` it embeds.";
52
+ readonly recovery: "Pass the text to match in `query`, or switch search_mode to keyword for a filter-only listing.";
53
+ }, {
54
+ readonly reason: "semantic_with_cursor";
55
+ readonly code: JsonRpcErrorCode.ValidationError;
56
+ readonly when: "A search set search_mode to \"semantic\" and supplied `cursor`, which OpenAlex rejects on a semantic query.";
57
+ readonly recovery: "Drop `cursor` and walk semantic results with `page` (1-based), or switch search_mode to keyword or exact, which paginate by cursor.";
58
+ }, {
59
+ readonly reason: "page_without_semantic";
60
+ readonly code: JsonRpcErrorCode.ValidationError;
61
+ readonly when: "A search supplied `page` under a search_mode other than \"semantic\".";
62
+ readonly recovery: "Drop `page` and paginate with `cursor`, or set search_mode to \"semantic\" — the only mode `page` applies to.";
47
63
  }, {
48
64
  readonly reason: "sample_with_cursor";
49
65
  readonly code: JsonRpcErrorCode.ValidationError;
50
66
  readonly when: "A search (no `id`) provided both `sample` and `cursor`.";
51
67
  readonly recovery: "Sampling returns a single page only; remove `cursor` or remove `sample`.";
68
+ }, {
69
+ readonly reason: "sample_with_page";
70
+ readonly code: JsonRpcErrorCode.ValidationError;
71
+ readonly when: "A search (no `id`) provided both `sample` and `page`.";
72
+ readonly recovery: "Sampling returns a single page only; remove `page` or remove `sample`.";
52
73
  }, {
53
74
  readonly reason: "seed_without_sample";
54
75
  readonly code: JsonRpcErrorCode.ValidationError;
@@ -59,70 +80,89 @@ export declare const searchEntitiesTool: import("@cyanheads/mcp-ts-core").ToolDe
59
80
  readonly code: JsonRpcErrorCode.NotFound;
60
81
  readonly when: "Lookup by id matched no OpenAlex entity.";
61
82
  readonly recovery: "Verify the ID format or call openalex_resolve_name to find the correct ID.";
83
+ readonly thrownBy: "service";
62
84
  }, {
63
85
  readonly reason: "rate_limited";
64
86
  readonly code: JsonRpcErrorCode.RateLimited;
65
87
  readonly when: "OpenAlex throttled the request for exceeding its per-second ceiling (HTTP 429).";
66
88
  readonly retryable: true;
67
89
  readonly recovery: "Wait several seconds and retry; consider lowering request frequency for this caller.";
90
+ readonly thrownBy: "service";
68
91
  }, {
69
92
  readonly reason: "upstream_budget_exhausted";
70
93
  readonly code: JsonRpcErrorCode.RateLimited;
71
94
  readonly when: "The OpenAlex daily usage budget is spent (HTTP 429).";
72
95
  readonly retryable: false;
73
96
  readonly recovery: "The daily budget refills at midnight UTC — retrying sooner will not succeed. Set OPENALEX_API_KEY to a free key (https://openalex.org/settings/api) for a larger daily budget than anonymous access, or wait for the reset.";
97
+ readonly thrownBy: "service";
74
98
  }, {
75
99
  readonly reason: "upstream_timeout";
76
100
  readonly code: JsonRpcErrorCode.Timeout;
77
101
  readonly when: "OpenAlex did not respond within the request deadline.";
78
102
  readonly retryable: true;
79
103
  readonly recovery: "Retry after a short delay; if timeouts persist, narrow the request with tighter filters to reduce upstream load.";
104
+ readonly thrownBy: "service";
80
105
  }, {
81
106
  readonly reason: "upstream_unavailable";
82
107
  readonly code: JsonRpcErrorCode.ServiceUnavailable;
83
108
  readonly when: "OpenAlex was unreachable or unusable — HTTP 503, a connection failure, or a body that was empty, HTML, or unparseable JSON.";
84
109
  readonly retryable: true;
85
110
  readonly recovery: "Wait and retry; check https://openalex.org for service status if the outage persists.";
111
+ readonly thrownBy: "service";
86
112
  }, {
87
113
  readonly reason: "upstream_unauthorized";
88
114
  readonly code: JsonRpcErrorCode.Unauthorized;
89
115
  readonly when: "OpenAlex rejected the API key (HTTP 401).";
90
116
  readonly recovery: "Check that OPENALEX_API_KEY is set to a valid OpenAlex account API key (free from https://openalex.org/settings/api).";
117
+ readonly thrownBy: "service";
91
118
  }, {
92
119
  readonly reason: "upstream_forbidden";
93
120
  readonly code: JsonRpcErrorCode.Forbidden;
94
121
  readonly when: "OpenAlex denied access to the requested resource (HTTP 403).";
95
122
  readonly recovery: "Confirm the API key has access to this entity type or endpoint, then retry the request.";
123
+ readonly thrownBy: "service";
96
124
  }, {
97
125
  readonly reason: "comma_in_filter_value";
98
126
  readonly code: JsonRpcErrorCode.InvalidParams;
99
127
  readonly when: "A filter value contains a comma, which collides with the OpenAlex filter separator.";
100
128
  readonly recovery: "Use `|` for OR within a filter value (e.g. \"2020|2021\"), or use a `.search` filter or the `query` parameter for free-text phrases that contain commas.";
129
+ readonly thrownBy: "service";
101
130
  }, {
102
131
  readonly reason: "upstream_invalid_params";
103
132
  readonly code: JsonRpcErrorCode.InvalidParams;
104
133
  readonly when: "OpenAlex rejected an invalid filter, select, or sort field name (HTTP 400).";
105
134
  readonly recovery: "The upstream message names the rejected field and suggests close matches. Use openalex_describe_fields(entity_type, context) to browse all valid fields for the given entity type and context.";
135
+ readonly thrownBy: "service";
106
136
  }, {
107
137
  readonly reason: "upstream_invalid_id_value";
108
138
  readonly code: JsonRpcErrorCode.InvalidParams;
109
139
  readonly when: "An entity-ID filter received a value that is not an OpenAlex ID — usually a name (HTTP 400).";
110
140
  readonly recovery: "Call openalex_resolve_name to turn the name into an OpenAlex ID, then filter by that ID. Entity filters such as authorships.author.id, primary_topic.id, and cites accept IDs only.";
141
+ readonly thrownBy: "service";
111
142
  }, {
112
143
  readonly reason: "upstream_sort_requires_search";
113
144
  readonly code: JsonRpcErrorCode.InvalidParams;
114
145
  readonly when: "sort=-relevance_score was used without an active search (HTTP 400).";
115
146
  readonly recovery: "Sorting by relevance_score requires an active search — add a `query` or a `*.search` filter (e.g. title.search), or choose a concrete sort field such as -cited_by_count or -publication_date.";
147
+ readonly thrownBy: "service";
148
+ }, {
149
+ readonly reason: "query_too_long";
150
+ readonly code: JsonRpcErrorCode.InvalidParams;
151
+ readonly when: "OpenAlex rejected `query` as longer than the search length it accepts (HTTP 400).";
152
+ readonly recovery: "Shorten `query` to the character limit the upstream message names, or split the text into several shorter searches and merge their results.";
153
+ readonly thrownBy: "service";
116
154
  }, {
117
155
  readonly reason: "upstream_invalid_params_other";
118
156
  readonly code: JsonRpcErrorCode.InvalidParams;
119
157
  readonly when: "OpenAlex rejected the request (HTTP 400) for a reason other than an invalid field name.";
120
158
  readonly recovery: "Read the upstream message in the error above and adjust the request — check filter operators, value formats, and cursor/per_page bounds.";
159
+ readonly thrownBy: "service";
121
160
  }, {
122
161
  readonly reason: "upstream_validation_failed";
123
162
  readonly code: JsonRpcErrorCode.ValidationError;
124
163
  readonly when: "OpenAlex rejected the request as semantically invalid (HTTP 422).";
125
164
  readonly recovery: "Read the upstream message for the specific field, then adjust the request to satisfy validation.";
165
+ readonly thrownBy: "service";
126
166
  }], {
127
167
  readonly echo: z.ZodString;
128
168
  readonly totalCount: z.ZodNumber;
@@ -1 +1 @@
1
- {"version":3,"file":"search-entities.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-entities.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA0DjE,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0X7B,CAAC"}
1
+ {"version":3,"file":"search-entities.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-entities.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA0EjE,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkgB7B,CAAC"}
@@ -16,9 +16,9 @@ const SAMPLE_MAX = 100;
16
16
  * alone, so every parameter named here is dropped on that path — by OpenAlex itself, which
17
17
  * answers an entity-by-ID URL identically whether or not search params ride along.
18
18
  *
19
- * Pagination (`per_page`, `cursor`) is deliberately absent: a singleton lookup is one record by
20
- * definition, and `per_page` always carries its schema default, so a caller's intent there
21
- * cannot be told apart from the absence of one. Naming it would be noise, not a warning.
19
+ * Pagination (`per_page`, `cursor`, `page`) is deliberately absent: a singleton lookup is one
20
+ * record by definition, and `per_page` always carries its schema default, so a caller's intent
21
+ * there cannot be told apart from the absence of one. Naming it would be noise, not a warning.
22
22
  */
23
23
  function searchOnlyParams(input) {
24
24
  const parts = [];
@@ -50,6 +50,18 @@ function buildSearchEcho(input) {
50
50
  return [...parts, `id=${input.id}`].join(' | ');
51
51
  return [...parts, ...searchOnlyParams(input).map((part) => part.rendered)].join(' | ');
52
52
  }
53
+ /**
54
+ * Is this call continuing a paginated traversal rather than opening one? A `cursor` is the
55
+ * keyword/exact signal — the schema rejects a blank one, so its presence is always a real
56
+ * continuation rather than a mistake OpenAlex answers with page 1; a `page` past the first is
57
+ * the semantic signal, and `page` only survives the handler's preconditions under semantic
58
+ * mode. Only on a continuing call does a page of
59
+ * zero results mean the traversal is finished — on a first call it means nothing matched, and
60
+ * the two want opposite advice.
61
+ */
62
+ function isContinuationPage(input) {
63
+ return input.cursor !== undefined || (input.page ?? 1) > 1;
64
+ }
53
65
  export const searchEntitiesTool = tool('openalex_search_entities', {
54
66
  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. 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.',
55
67
  sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/search-entities.tool.ts',
@@ -61,12 +73,36 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
61
73
  when: `A search (no \`id\`) set per_page above the semantic-search cap of ${SEMANTIC_PER_PAGE_CAP}.`,
62
74
  recovery: `Reduce per_page to ${SEMANTIC_PER_PAGE_CAP} or less, or switch search_mode to keyword.`,
63
75
  },
76
+ {
77
+ reason: 'semantic_without_query',
78
+ code: JsonRpcErrorCode.ValidationError,
79
+ when: 'A search set search_mode to "semantic" without supplying the `query` it embeds.',
80
+ recovery: 'Pass the text to match in `query`, or switch search_mode to keyword for a filter-only listing.',
81
+ },
82
+ {
83
+ reason: 'semantic_with_cursor',
84
+ code: JsonRpcErrorCode.ValidationError,
85
+ when: 'A search set search_mode to "semantic" and supplied `cursor`, which OpenAlex rejects on a semantic query.',
86
+ recovery: 'Drop `cursor` and walk semantic results with `page` (1-based), or switch search_mode to keyword or exact, which paginate by cursor.',
87
+ },
88
+ {
89
+ reason: 'page_without_semantic',
90
+ code: JsonRpcErrorCode.ValidationError,
91
+ when: 'A search supplied `page` under a search_mode other than "semantic".',
92
+ recovery: 'Drop `page` and paginate with `cursor`, or set search_mode to "semantic" — the only mode `page` applies to.',
93
+ },
64
94
  {
65
95
  reason: 'sample_with_cursor',
66
96
  code: JsonRpcErrorCode.ValidationError,
67
97
  when: 'A search (no `id`) provided both `sample` and `cursor`.',
68
98
  recovery: 'Sampling returns a single page only; remove `cursor` or remove `sample`.',
69
99
  },
100
+ {
101
+ reason: 'sample_with_page',
102
+ code: JsonRpcErrorCode.ValidationError,
103
+ when: 'A search (no `id`) provided both `sample` and `page`.',
104
+ recovery: 'Sampling returns a single page only; remove `page` or remove `sample`.',
105
+ },
70
106
  {
71
107
  reason: 'seed_without_sample',
72
108
  code: JsonRpcErrorCode.ValidationError,
@@ -78,6 +114,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
78
114
  code: JsonRpcErrorCode.NotFound,
79
115
  when: 'Lookup by id matched no OpenAlex entity.',
80
116
  recovery: 'Verify the ID format or call openalex_resolve_name to find the correct ID.',
117
+ thrownBy: 'service',
81
118
  },
82
119
  {
83
120
  reason: 'rate_limited',
@@ -85,6 +122,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
85
122
  when: 'OpenAlex throttled the request for exceeding its per-second ceiling (HTTP 429).',
86
123
  retryable: true,
87
124
  recovery: 'Wait several seconds and retry; consider lowering request frequency for this caller.',
125
+ thrownBy: 'service',
88
126
  },
89
127
  {
90
128
  reason: 'upstream_budget_exhausted',
@@ -92,6 +130,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
92
130
  when: 'The OpenAlex daily usage budget is spent (HTTP 429).',
93
131
  retryable: false,
94
132
  recovery: 'The daily budget refills at midnight UTC — retrying sooner will not succeed. Set OPENALEX_API_KEY to a free key (https://openalex.org/settings/api) for a larger daily budget than anonymous access, or wait for the reset.',
133
+ thrownBy: 'service',
95
134
  },
96
135
  {
97
136
  reason: 'upstream_timeout',
@@ -99,6 +138,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
99
138
  when: 'OpenAlex did not respond within the request deadline.',
100
139
  retryable: true,
101
140
  recovery: 'Retry after a short delay; if timeouts persist, narrow the request with tighter filters to reduce upstream load.',
141
+ thrownBy: 'service',
102
142
  },
103
143
  {
104
144
  reason: 'upstream_unavailable',
@@ -106,74 +146,93 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
106
146
  when: 'OpenAlex was unreachable or unusable — HTTP 503, a connection failure, or a body that was empty, HTML, or unparseable JSON.',
107
147
  retryable: true,
108
148
  recovery: 'Wait and retry; check https://openalex.org for service status if the outage persists.',
149
+ thrownBy: 'service',
109
150
  },
110
151
  {
111
152
  reason: 'upstream_unauthorized',
112
153
  code: JsonRpcErrorCode.Unauthorized,
113
154
  when: 'OpenAlex rejected the API key (HTTP 401).',
114
155
  recovery: 'Check that OPENALEX_API_KEY is set to a valid OpenAlex account API key (free from https://openalex.org/settings/api).',
156
+ thrownBy: 'service',
115
157
  },
116
158
  {
117
159
  reason: 'upstream_forbidden',
118
160
  code: JsonRpcErrorCode.Forbidden,
119
161
  when: 'OpenAlex denied access to the requested resource (HTTP 403).',
120
162
  recovery: 'Confirm the API key has access to this entity type or endpoint, then retry the request.',
163
+ thrownBy: 'service',
121
164
  },
122
165
  {
123
166
  reason: 'comma_in_filter_value',
124
167
  code: JsonRpcErrorCode.InvalidParams,
125
168
  when: 'A filter value contains a comma, which collides with the OpenAlex filter separator.',
126
169
  recovery: 'Use `|` for OR within a filter value (e.g. "2020|2021"), or use a `.search` filter or the `query` parameter for free-text phrases that contain commas.',
170
+ thrownBy: 'service',
127
171
  },
128
172
  {
129
173
  reason: 'upstream_invalid_params',
130
174
  code: JsonRpcErrorCode.InvalidParams,
131
175
  when: 'OpenAlex rejected an invalid filter, select, or sort field name (HTTP 400).',
132
176
  recovery: 'The upstream message names the rejected field and suggests close matches. Use openalex_describe_fields(entity_type, context) to browse all valid fields for the given entity type and context.',
177
+ thrownBy: 'service',
133
178
  },
134
179
  {
135
180
  reason: 'upstream_invalid_id_value',
136
181
  code: JsonRpcErrorCode.InvalidParams,
137
182
  when: 'An entity-ID filter received a value that is not an OpenAlex ID — usually a name (HTTP 400).',
138
183
  recovery: 'Call openalex_resolve_name to turn the name into an OpenAlex ID, then filter by that ID. Entity filters such as authorships.author.id, primary_topic.id, and cites accept IDs only.',
184
+ thrownBy: 'service',
139
185
  },
140
186
  {
141
187
  reason: 'upstream_sort_requires_search',
142
188
  code: JsonRpcErrorCode.InvalidParams,
143
189
  when: 'sort=-relevance_score was used without an active search (HTTP 400).',
144
190
  recovery: 'Sorting by relevance_score requires an active search — add a `query` or a `*.search` filter (e.g. title.search), or choose a concrete sort field such as -cited_by_count or -publication_date.',
191
+ thrownBy: 'service',
192
+ },
193
+ {
194
+ reason: 'query_too_long',
195
+ code: JsonRpcErrorCode.InvalidParams,
196
+ when: 'OpenAlex rejected `query` as longer than the search length it accepts (HTTP 400).',
197
+ recovery: 'Shorten `query` to the character limit the upstream message names, or split the text into several shorter searches and merge their results.',
198
+ thrownBy: 'service',
145
199
  },
146
200
  {
147
201
  reason: 'upstream_invalid_params_other',
148
202
  code: JsonRpcErrorCode.InvalidParams,
149
203
  when: 'OpenAlex rejected the request (HTTP 400) for a reason other than an invalid field name.',
150
204
  recovery: 'Read the upstream message in the error above and adjust the request — check filter operators, value formats, and cursor/per_page bounds.',
205
+ thrownBy: 'service',
151
206
  },
152
207
  {
153
208
  reason: 'upstream_validation_failed',
154
209
  code: JsonRpcErrorCode.ValidationError,
155
210
  when: 'OpenAlex rejected the request as semantically invalid (HTTP 422).',
156
211
  recovery: 'Read the upstream message for the specific field, then adjust the request to satisfy validation.',
212
+ thrownBy: 'service',
157
213
  },
158
214
  ],
215
+ inputAliases: { search: 'query', filter: 'filters' },
159
216
  input: z.object({
160
217
  entity_type: z.enum(ENTITY_TYPES).describe('Type of scholarly entity to search.'),
161
218
  id: z
162
219
  .string()
220
+ .min(1)
163
221
  .optional()
164
- .describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807"), DOI ("10.1038/nature12373"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361"), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). 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). To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
222
+ .describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807"), DOI ("10.1038/nature12373"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361"), 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). To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
165
223
  query: z
166
224
  .string()
225
+ .min(1)
167
226
  .optional()
168
- .describe('Text search query. Supports boolean operators (AND, OR, NOT), quoted phrases ("exact match"), wildcards (machin*), fuzzy matching (machin~1), and proximity ("climate change"~5). Omit for filter-only queries.'),
227
+ .describe('Text search query. Supports boolean operators (AND, OR, NOT), quoted phrases ("exact match"), wildcards (machin*), fuzzy matching (machin~1), and proximity ("climate change"~5). Omit for filter-only queries — an empty string is rejected, since a blank search is a mistake rather than a request for the whole catalog.'),
169
228
  search_mode: z
170
229
  .enum(['keyword', 'exact', 'semantic'])
171
230
  .default('keyword')
172
- .describe('Search strategy. "keyword": stemmed full-text (default). "exact": no stemming, matches individual words (use quoted phrases for multi-word exact match). "semantic": AI embedding similarity (max 50 results, 1 req/sec).'),
231
+ .describe(`Search strategy. "keyword": stemmed full-text (default). "exact": no stemming, matches individual words (use quoted phrases for multi-word exact match). "semantic": AI embedding similarity, ranking at most ${SEMANTIC_PER_PAGE_CAP} candidates at ~1 req/sec and paginated with \`page\` rather than \`cursor\`.`),
173
232
  filters: z
174
233
  .record(z.string(), z.string())
175
234
  .optional()
176
- .describe('Filter criteria as field:value pairs. AND across fields (multiple keys). OR within field: pipe-separate ("us|gb"). NOT: prefix "!" ("!us"). Range: "2020-2024". Comparison: ">100", "<50". AND within same field: "+"-separate. Use OpenAlex IDs (not names) for entity filters — resolve names first. Common keys: `openalex` (filter by entity ID, e.g. {"openalex": "W123|W456"}), `cites` (works citing a given work), `publication_year` (range "2020-2024"), `authorships.author.id`, `type`, `is_oa`.'),
235
+ .describe('Filter criteria as field:value pairs. AND across fields (multiple keys). OR within field: pipe-separate ("us|gb"). NOT: prefix "!" ("!us"). Range: "2020-2024". Comparison: ">100", "<50". AND within same field: "+"-separate. Two keys that resolve to the same upstream field (an alias and its canonical name, e.g. `year` and `publication_year`) are both applied and AND\'d, so they narrow rather than override each other. Use OpenAlex IDs (not names) for entity filters — resolve names first. Common keys: `openalex` (filter by entity ID, e.g. {"openalex": "W123|W456"}), `cites` (works citing a given work), `publication_year` (range "2020-2024"), `authorships.author.id`, `type`, `is_oa`.'),
177
236
  sort: z
178
237
  .string()
179
238
  .optional()
@@ -191,15 +250,22 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
191
250
  .describe('Results per page (1-100). Default 25. Semantic search caps at 50 — when search_mode="semantic", set per_page ≤ 50 (also subject to a 1 req/sec rate limit upstream). The cap applies to searches only; an `id` lookup returns its one record regardless of both.'),
192
251
  cursor: z
193
252
  .string()
253
+ .min(1)
194
254
  .optional()
195
- .describe('Pagination cursor from a previous response. Pass to get the next page.'),
255
+ .describe('Pagination cursor from a previous response. Pass to get the next page. Omit it on the first call — an empty string is rejected, since a supplied-but-blank cursor is a caller mistake rather than a request for page 1. Keyword and exact modes only — semantic search walks its candidates with `page`, and a `cursor` sent with it is rejected.'),
256
+ page: z
257
+ .number()
258
+ .int()
259
+ .min(1)
260
+ .optional()
261
+ .describe(`Page number (1-based) for semantic search, the one mode that paginates with \`page\` instead of \`cursor\`. Semantic search ranks at most ${SEMANTIC_PER_PAGE_CAP} candidates, so the last reachable page is ceil(${SEMANTIC_PER_PAGE_CAP} / per_page) — e.g. page 17 with per_page=3. Passing it under any other search_mode is rejected.`),
196
262
  sample: z
197
263
  .number()
198
264
  .int()
199
265
  .min(1)
200
266
  .max(SAMPLE_MAX)
201
267
  .optional()
202
- .describe(`Return a random sample of this many entities matching the filters (1-${SAMPLE_MAX}). Single page only — pagination via \`cursor\` is not supported with sampling. Overrides \`per_page\`. Useful for unbiased exploration: spot-checking filter correctness, stratified review prompts, or generating exploration sets without bias toward most-cited.`),
268
+ .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. Overrides \`per_page\`. Useful for unbiased exploration: spot-checking filter correctness, stratified review prompts, or generating exploration sets without bias toward most-cited.`),
203
269
  seed: z
204
270
  .string()
205
271
  .optional()
@@ -208,8 +274,12 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
208
274
  output: z.object({
209
275
  meta: z
210
276
  .object({
211
- count: z.number().describe('Total results matching the query/filters.'),
212
- per_page: z.number().describe('Results on this page.'),
277
+ count: z
278
+ .number()
279
+ .describe(`Total results matching the query/filters. Under search_mode "semantic" it is instead the capped candidate count — at most ${SEMANTIC_PER_PAGE_CAP} — not an exhaustive match total.`),
280
+ per_page: z
281
+ .number()
282
+ .describe('Page size OpenAlex echoed for this request — the requested per_page, not the number of records returned. A short or exhausted page carries fewer records than this.'),
213
283
  next_cursor: z
214
284
  .string()
215
285
  .nullable()
@@ -240,7 +310,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
240
310
  notice: z
241
311
  .string()
242
312
  .optional()
243
- .describe('Guidance notice. Set when results are empty (echoes the criteria and suggests how to broaden) or when an `id` lookup was passed search criteria it does not apply (names them). Absent otherwise.'),
313
+ .describe('Guidance notice. Set when a first call returns no results (echoes the criteria and suggests how to broaden), when a paginated call ran past its last page (says the traversal is finished instead of advising a broader query), when an `id` lookup was passed search criteria it does not apply (names them), and on every semantic search to disclose that `meta.count` is a capped candidate total. Absent otherwise.'),
244
314
  budget: z
245
315
  .object({
246
316
  costUsd: z
@@ -265,14 +335,20 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
265
335
  },
266
336
  async handler(input, ctx) {
267
337
  /**
268
- * All three checks below constrain a *list* query. `id` takes the singleton path in
269
- * `OpenAlexService.search()`, which reads only `entity_type`, `id`, and `select` — so
270
- * search_mode, per_page, cursor, sample, and seed are unread there, and rejecting an ID
271
- * lookup over how they relate to each other fails it on constraints it never met. The
272
- * dropped-parameter notice below reports them instead. The `id` predicate is truthiness,
273
- * not `!== undefined`, to match the service branch: an empty-string `id` parses and lists.
338
+ * Reject list-query parameter combinations OpenAlex cannot serve. Every check here
339
+ * constrains a *list* query. `id` takes the singleton path in `OpenAlexService.search()`,
340
+ * which reads only `entity_type`, `id`, and `select` — so search_mode, per_page, cursor,
341
+ * page, sample, and seed are unread there, and rejecting an ID lookup over how they relate
342
+ * to each other fails it on constraints it never met. The dropped-parameter notice below
343
+ * reports them instead. The `id` predicate mirrors the service's own truthiness branch.
274
344
  */
275
345
  if (!input.id) {
346
+ // Semantic search matches an embedding of the query text, so with no query there is
347
+ // nothing to embed — the request silently degraded to a plain unfiltered listing whose
348
+ // echo still advertised `search_mode=semantic`.
349
+ if (input.search_mode === 'semantic' && !input.query) {
350
+ throw ctx.fail('semantic_without_query', 'Semantic search needs `query` text to embed — none was supplied.', { ...ctx.recoveryFor('semantic_without_query'), searchMode: input.search_mode });
351
+ }
276
352
  if (input.search_mode === 'semantic' && input.per_page > SEMANTIC_PER_PAGE_CAP) {
277
353
  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.`, {
278
354
  ...ctx.recoveryFor('semantic_per_page_cap'),
@@ -281,9 +357,33 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
281
357
  cap: SEMANTIC_PER_PAGE_CAP,
282
358
  });
283
359
  }
360
+ // OpenAlex answers `search.semantic` plus a cursor with a 400 ("Cursor pagination is not
361
+ // supported with semantic search"), so forwarding it spends a round trip to learn that.
362
+ if (input.search_mode === 'semantic' && input.cursor !== undefined) {
363
+ throw ctx.fail('semantic_with_cursor', 'Semantic search does not accept `cursor` — walk its candidates with `page` (1-based) instead.', {
364
+ ...ctx.recoveryFor('semantic_with_cursor'),
365
+ searchMode: input.search_mode,
366
+ cursor: input.cursor,
367
+ });
368
+ }
369
+ // The inverse: `page` is the semantic-only knob, and OpenAlex ignores it on a cursor
370
+ // traversal — silently returning page 1 again rather than the page that was asked for.
371
+ if (input.page !== undefined && input.search_mode !== 'semantic') {
372
+ throw ctx.fail('page_without_semantic', `\`page\` applies to semantic search only — search_mode is "${input.search_mode}", which paginates with \`cursor\`.`, {
373
+ ...ctx.recoveryFor('page_without_semantic'),
374
+ searchMode: input.search_mode,
375
+ page: input.page,
376
+ });
377
+ }
284
378
  if (input.sample !== undefined && input.cursor !== undefined) {
285
379
  throw ctx.fail('sample_with_cursor', 'Sampling returns one page only — `sample` cannot be combined with `cursor` pagination.', { ...ctx.recoveryFor('sample_with_cursor'), sample: input.sample, cursor: input.cursor });
286
380
  }
381
+ // Sampling pins `per_page` to `sample` and returns that one page, so there is no second
382
+ // page for `page` to address — the same exclusion `cursor` has above. Reachable under
383
+ // semantic mode alone; every other mode rejects `page` at the check before this one.
384
+ if (input.sample !== undefined && input.page !== undefined) {
385
+ throw ctx.fail('sample_with_page', 'Sampling returns one page only — `sample` cannot be combined with `page` pagination.', { ...ctx.recoveryFor('sample_with_page'), sample: input.sample, page: input.page });
386
+ }
287
387
  if (input.seed !== undefined && input.sample === undefined) {
288
388
  throw ctx.fail('seed_without_sample', '`seed` is only meaningful with `sample` — pass `sample` to enable random sampling.', { ...ctx.recoveryFor('seed_without_sample'), seed: input.seed });
289
389
  }
@@ -299,6 +399,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
299
399
  select: input.select,
300
400
  perPage: input.per_page,
301
401
  cursor: input.cursor,
402
+ page: input.page,
302
403
  sample: input.sample,
303
404
  seed: input.seed,
304
405
  }, ctx);
@@ -311,17 +412,28 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
311
412
  });
312
413
  const echo = buildSearchEcho(input);
313
414
  ctx.enrich({ echo, totalCount: result.meta.count });
415
+ const notices = [];
314
416
  // An `id` lookup always returns its one record or throws, so it never reaches the
315
417
  // empty-results notice — the two branches are exclusive, and the dropped-parameter
316
418
  // warning is the more useful of the two on that path.
317
419
  const ignored = input.id ? searchOnlyParams(input).map((part) => part.name) : [];
318
420
  if (ignored.length > 0) {
319
421
  const [verb, pronoun] = ignored.length === 1 ? ['was', 'it'] : ['were', 'them'];
320
- ctx.enrich.notice(`\`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.`);
422
+ 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.`);
321
423
  }
322
424
  else if (result.results.length === 0) {
323
- ctx.enrich.notice(`No matches for ${echo}. Try broadening the query, removing filters, or switching search_mode.`);
425
+ notices.push(isContinuationPage(input)
426
+ ? `Pagination exhausted for ${echo} — the previous page was the last, so this one came back empty. Stop paging rather than broadening the search.`
427
+ : `No matches for ${echo}. Try broadening the query, removing filters, or switching search_mode.`);
428
+ }
429
+ // `meta.count` reports the ranked candidate set under semantic search, not the population
430
+ // matching the query — a caller reading it as a match total plans a traversal that ends
431
+ // at the cap.
432
+ if (!input.id && input.search_mode === 'semantic') {
433
+ notices.push(`Semantic search ranks a capped candidate set, so \`meta.count\` (${result.meta.count}) is that ceiling — at most ${SEMANTIC_PER_PAGE_CAP} — not the total number of matching records. Reach the rest of the candidates with \`page\`.`);
324
434
  }
435
+ if (notices.length > 0)
436
+ ctx.enrich.notice(notices.join(' '));
325
437
  return {
326
438
  meta: {
327
439
  count: result.meta.count,