@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.
- package/AGENTS.md +25 -10
- package/CLAUDE.md +25 -10
- package/README.md +167 -88
- package/changelog/0.7.x/0.7.14.md +28 -0
- package/changelog/0.7.x/0.7.15.md +33 -0
- package/changelog/template.md +7 -7
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +19 -0
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +32 -3
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +12 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +55 -33
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +11 -0
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +13 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +40 -0
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +132 -20
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/services/openalex/openalex-service.d.ts +16 -0
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +105 -29
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/types.d.ts +11 -0
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +7 -0
- package/dist/services/openalex/types.js.map +1 -1
- package/package.json +7 -7
- 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":"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;
|
|
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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"}
|
|
@@ -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;
|
|
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
|
|
20
|
-
* definition, and `per_page` always carries its schema default, so a caller's intent
|
|
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(
|
|
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 —
|
|
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
|
|
212
|
-
|
|
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
|
|
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
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|