@cyanheads/openalex-mcp-server 0.7.15 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +32 -30
- package/CLAUDE.md +32 -30
- package/Dockerfile +88 -32
- package/README.md +12 -6
- package/changelog/0.7.x/0.7.16.md +32 -0
- package/changelog/0.8.x/0.8.0.md +38 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +4 -3
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +62 -19
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +21 -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 +42 -42
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js +82 -15
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts +8 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +18 -7
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +45 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +140 -64
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/escape-markdown.d.ts +32 -0
- package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -0
- package/dist/mcp-server/tools/escape-markdown.js +389 -0
- package/dist/mcp-server/tools/escape-markdown.js.map +1 -0
- package/dist/mcp-server/tools/render-entity-record.d.ts +14 -2
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +46 -15
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +159 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +480 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/services/openalex/html-entities.json +2237 -0
- package/dist/services/openalex/openalex-service.d.ts +14 -1
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +147 -101
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/provider-text.d.ts +27 -0
- package/dist/services/openalex/provider-text.d.ts.map +1 -0
- package/dist/services/openalex/provider-text.js +600 -0
- package/dist/services/openalex/provider-text.js.map +1 -0
- package/dist/services/openalex/types.d.ts +6 -1
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +1 -1
- package/package.json +11 -7
- package/server.json +3 -3
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Provider text arrives clean on both surfaces (entities decoded, markup removed, Markdown escaped in content[]), plus a flagged unknown group_by bucket, local rejection of sample with semantic search or sort, and corrected group_by field lists and error reasons."
|
|
3
|
+
breaking: false
|
|
4
|
+
security: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 0.7.16 — 2026-09-24
|
|
8
|
+
|
|
9
|
+
## Added
|
|
10
|
+
|
|
11
|
+
- **`is_unknown`** on `openalex_analyze_trends` groups — set on the missing-value bucket `include_unknown` adds (keyed `-111`, `-111.0`, `unknown`, or an ID ending in `/unknown`). The text output labels it `unknown (no value; key …)` and renders it after the last year of a time series. ([#75](https://github.com/cyanheads/openalex-mcp-server/issues/75))
|
|
12
|
+
- **`sample_with_semantic`** and **`sample_with_sort`** error reasons on `openalex_search_entities` — `sample` with `search_mode: "semantic"` (which OpenAlex does not sample) or with any `sort` (which OpenAlex refuses) is rejected before the upstream call. `id` lookups still drop both with a notice. ([#83](https://github.com/cyanheads/openalex-mcp-server/issues/83), [#85](https://github.com/cyanheads/openalex-mcp-server/issues/85))
|
|
13
|
+
|
|
14
|
+
## Changed
|
|
15
|
+
|
|
16
|
+
- **Provider text in `structuredContent`** — string values now carry decoded HTML entities (full WHATWG table) with HTML comments removed and HTML/JATS/MathML formatting tags unwrapped; literal text such as `A < B` or `Fish <Actinopterygii>` is kept. Affected records return different values than before. ([#76](https://github.com/cyanheads/openalex-mcp-server/issues/76))
|
|
17
|
+
- **`content[]` escaping** — every `format()` backslash-escapes provider text for Markdown, so a stray `*`, `<tag>`, or `[x](y)` renders as written; IDs and URLs that render as links stay byte-identical. ([#76](https://github.com/cyanheads/openalex-mcp-server/issues/76))
|
|
18
|
+
- **`openalex_analyze_trends` group `key`** is passed through raw, exactly as OpenAlex returns it (it was entity-decoded before); `key_display_name` gets the provider-text normalization. ([#76](https://github.com/cyanheads/openalex-mcp-server/issues/76))
|
|
19
|
+
|
|
20
|
+
## Fixed
|
|
21
|
+
|
|
22
|
+
- **Named-entity decoding** no longer resolves `Object.prototype` names — `&constructor;` stays literal instead of becoming `function Object() { [native code] }`. ([#76](https://github.com/cyanheads/openalex-mcp-server/issues/76))
|
|
23
|
+
- **`openalex_describe_fields` `group_by` view** drops the fields OpenAlex refuses to aggregate — decimal scores such as `fwci`, several source year fields, `display_name`, external-ID fields, and the concept keys on authors — and five works fields whose grouping fails with an HTTP 500 on every attempt (`cites`, `has_embeddings`, and the three `raw_type` location fields), per a live sweep of every listed field on all eight entity types; they stay valid filters. ([#86](https://github.com/cyanheads/openalex-mcp-server/issues/86))
|
|
24
|
+
- **`group_by` rejections** on authors' concept keys (reported upstream as an invalid ID) and on `referenced_works` now surface as `upstream_ungroupable_group_by`, whose recovery points at `openalex_describe_fields`; an invalid-ID 400 quoting a value from the request's `filter` keeps `upstream_invalid_id_value`. ([#87](https://github.com/cyanheads/openalex-mcp-server/issues/87))
|
|
25
|
+
- **`openalex_resolve_name`** reports the HTTP 500 that typed autocomplete returns for a `query` over 1,000 code points as `query_too_long` after one attempt, instead of a retried `upstream_unavailable` outage. ([#81](https://github.com/cyanheads/openalex-mcp-server/issues/81))
|
|
26
|
+
- **Semantic `meta.count`** — descriptions and the runtime notice no longer claim a 50-candidate ceiling; the candidate count is query-dependent and can exceed 50 (the 50-per-page cap stands). ([#84](https://github.com/cyanheads/openalex-mcp-server/issues/84))
|
|
27
|
+
|
|
28
|
+
## Dependencies
|
|
29
|
+
|
|
30
|
+
- `mdast-util-from-markdown` ^2.0.3 (new devDependency)
|
|
31
|
+
- `mdast-util-gfm` ^3.1.0 (new devDependency)
|
|
32
|
+
- `micromark-extension-gfm` ^3.0.0 (new devDependency)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Search and citation-graph responses hold to a 64,000-byte budget, with continuations for cut records and a new slice input for windowed arrays, alongside provider-text and identifier fixes."
|
|
3
|
+
breaking: false
|
|
4
|
+
security: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 0.8.0 — 2026-09-30
|
|
8
|
+
|
|
9
|
+
## Added
|
|
10
|
+
|
|
11
|
+
- **`slice: {field, offset}`** on `openalex_search_entities` `id` lookups — pages one top-level array of the record from `offset`, as many elements as fit the budget (at least one), with the window and its `next` call in `windows`; it replaces `select`. `slice_without_id` and `slice_field_not_array` reject a `slice` with no `id`, or one naming `*` or a field that is not an array. ([#72](https://github.com/cyanheads/openalex-mcp-server/issues/72))
|
|
12
|
+
|
|
13
|
+
## Changed
|
|
14
|
+
|
|
15
|
+
- **Response budget on `openalex_search_entities` and `openalex_get_citation_graph`** — `structuredContent` and `content[]` each hold to 64,000 bytes. An oversized page returns fewer records and names the rest in a new `omitted` field, whose `next` is the `openalex_search_entities` call that returns them (an `openalex` ID filter, `id` for keywords); `next_cursor` is unchanged. ([#72](https://github.com/cyanheads/openalex-mcp-server/issues/72))
|
|
16
|
+
- **Array windows** — a record too large on its own keeps its non-array fields and returns its largest arrays in part, each listed in a new `windows` field with an `id` + `slice` `next` call; `over_budget: true` marks a response whose smallest form still exceeds the budget. ([#72](https://github.com/cyanheads/openalex-mcp-server/issues/72))
|
|
17
|
+
- **`openalex_literature_review`** step 3 names the `slice` call that pages a windowed `authorships` array. ([#72](https://github.com/cyanheads/openalex-mcp-server/issues/72))
|
|
18
|
+
- **Error envelopes** carry `data.requestId`, and a tool error's `content[]` closes with the request ID (mcp-ts-core 0.13.10).
|
|
19
|
+
- **Docker image** — production dependencies and the OTel peers install in a `$BUILDPLATFORM` `deps` stage that cross-targets the image platform; the image gains a `HEALTHCHECK` on `/healthz` and an `org.opencontainers.image.version` label, and no longer sets `MCP_FORCE_CONSOLE_LOGGING`.
|
|
20
|
+
- **Packaging** — the npm package no longer ships `dist/*.mcpb`, the Docker build context excludes the MCP Registry token files and `.env.bak-*` backups, and the `.mcpb` bundle excludes `docs/idea.md` and `logs/`.
|
|
21
|
+
|
|
22
|
+
## Fixed
|
|
23
|
+
|
|
24
|
+
- **Provider-text delimiters** — CDATA sections, including unterminated ones and ones closed by a bare `]]`, and IOP's `<?CDATA …?>` are unwrapped with their TeX kept, the `<?MML …?>` restating them is removed, an unclosed `<!--inline-formula>` opener is removed as a tag, and a literal `\n` becomes a space; TeX such as `\nu` is kept. ([#89](https://github.com/cyanheads/openalex-mcp-server/issues/89))
|
|
25
|
+
- **Identifier and URL fields** (such as `id`, `doi`, `ids`, `orcid`, `ror`, `issn_l`, and every `*_url`) are returned exactly as OpenAlex stores them rather than entity-decoded, so a returned DOI resolves when passed back. ([#94](https://github.com/cyanheads/openalex-mcp-server/issues/94))
|
|
26
|
+
- **Possibly capped authorships** — a search or citation-graph work carrying exactly 100 `authorships`, the most OpenAlex returns on a list record, gets a `windows` entry with `possibly_capped: true` whose `next` is the `id` + `slice` call for the rest. ([#73](https://github.com/cyanheads/openalex-mcp-server/issues/73))
|
|
27
|
+
- **`openalex_analyze_trends` on `authorships.countries`** — a works group_by on it notes that OpenAlex counts only each work's first 100 authorships and names `authorships.institutions.country_code`, which counts every one. ([#91](https://github.com/cyanheads/openalex-mcp-server/issues/91))
|
|
28
|
+
- **`upstream_budget_exhausted` recovery** on all four tools says only that the server's daily budget refills at midnight UTC; the `OPENALEX_API_KEY` advice is added only when the server sends no key. ([#90](https://github.com/cyanheads/openalex-mcp-server/issues/90))
|
|
29
|
+
- **`Object.prototype` names** — `select` fields and works `filter` keys such as `constructor` or `__proto__` pass through unchanged instead of resolving to the inherited member, `openalex_resolve_name` sends a `constructor:`-prefixed query to autocomplete, and a record key named `__proto__` survives normalization. ([#93](https://github.com/cyanheads/openalex-mcp-server/issues/93))
|
|
30
|
+
|
|
31
|
+
## Dependencies
|
|
32
|
+
|
|
33
|
+
- `@cyanheads/mcp-ts-core` ^0.13.6 → ^0.13.10 (framework skills and scripts resynced)
|
|
34
|
+
- `@socketsecurity/bun-security-scanner` ^1.1.2 → ^1.1.3 (dev)
|
|
35
|
+
- `@types/node` ^26.6.2 → ^26.6.3 (dev)
|
|
36
|
+
- `ignore` ^7.0.9 → ^7.0.10 (dev)
|
|
37
|
+
- `vitest` ^5.0.1 → ^5.0.2 (dev)
|
|
38
|
+
- `packageManager` and Docker base images bun 1.4.0 → 1.4.2
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ await createApp({
|
|
|
25
25
|
describeFieldsTool,
|
|
26
26
|
],
|
|
27
27
|
prompts: [literatureReviewPrompt, researchLandscapePrompt],
|
|
28
|
-
instructions:
|
|
28
|
+
instructions: "Use the openalex_* tools to query the OpenAlex scholarly catalog (works, authors, sources, institutions, topics, keywords, publishers, funders): resolve names to IDs, search/filter/sort or fetch by ID, and group_by for trends. Names are ambiguous and IDs are not — call openalex_resolve_name before filtering by entity. Record responses hold to 64,000 bytes per surface: a cut page names its `omitted` IDs and the call that returns them (in OpenAlex's order), and an array too long to fit comes back as a window that an `id` lookup pages with `slice`.",
|
|
29
29
|
landing: {
|
|
30
30
|
tagline: 'Search the OpenAlex catalog — 270M+ works, 90M+ authors, 100K+ sources.',
|
|
31
31
|
requireAuth: false,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,8DAA8D,CAAC;AACtG,OAAO,EAAE,uBAAuB,EAAE,MAAM,+DAA+D,CAAC;AACxG,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,uDAAuD,CAAC;AAC7F,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,eAAe,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,qBAAqB;IAC3B,KAAK,EAAE,qBAAqB;IAC5B,qFAAqF;IACrF,WAAW,EAAE,WAAW;IACxB,KAAK,EAAE;QACL,eAAe;QACf,kBAAkB;QAClB,iBAAiB;QACjB,oBAAoB;QACpB,kBAAkB;KACnB;IACD,OAAO,EAAE,CAAC,sBAAsB,EAAE,uBAAuB,CAAC;IAC1D,YAAY,EACV,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,8DAA8D,CAAC;AACtG,OAAO,EAAE,uBAAuB,EAAE,MAAM,+DAA+D,CAAC;AACxG,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,uDAAuD,CAAC;AAC7F,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,eAAe,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,qBAAqB;IAC3B,KAAK,EAAE,qBAAqB;IAC5B,qFAAqF;IACrF,WAAW,EAAE,WAAW;IACxB,KAAK,EAAE;QACL,eAAe;QACf,kBAAkB;QAClB,iBAAiB;QACjB,oBAAoB;QACpB,kBAAkB;KACnB;IACD,OAAO,EAAE,CAAC,sBAAsB,EAAE,uBAAuB,CAAC;IAC1D,YAAY,EACV,yiBAAyiB;IAC3iB,OAAO,EAAE;QACP,OAAO,EAAE,yEAAyE;QAClF,WAAW,EAAE,KAAK;QAClB,QAAQ,EAAE,kDAAkD;QAC5D,UAAU,EAAE;YACV,gBAAgB,EAAE,uBAAuB;SAC1C;QACD,KAAK,EAAE;YACL,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,sBAAsB,EAAE;YACnD,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,iCAAiC,EAAE;SAC/D;KACF;IACD,KAAK;QACH,mBAAmB,EAAE,CAAC;IACxB,CAAC;CACF,CAAC,CAAC"}
|
|
@@ -41,7 +41,7 @@ Follow this workflow using the OpenAlex tools:
|
|
|
41
41
|
- Search with multiple related topic IDs to capture adjacent areas`}
|
|
42
42
|
Use select to keep payloads manageable.
|
|
43
43
|
|
|
44
|
-
3. **Identify key papers** — Re-run the step 2 search with sort: "-cited_by_count" to surface landmark works, keeping the topical filters in place: an explicit sort replaces relevance ranking, so without them you get the most-cited works in the catalog rather than in "${args.topic}". Then call openalex_search_entities with id and a select naming the fields you need (e.g. ["abstract", "authorships", "primary_location"]) for the most important ones — an id lookup on its own returns the curated default projection.
|
|
44
|
+
3. **Identify key papers** — Re-run the step 2 search with sort: "-cited_by_count" to surface landmark works, keeping the topical filters in place: an explicit sort replaces relevance ranking, so without them you get the most-cited works in the catalog rather than in "${args.topic}". Then call openalex_search_entities with id and a select naming the fields you need (e.g. ["abstract", "authorships", "primary_location"]) for the most important ones — an id lookup on its own returns the curated default projection. A long array such as authorships on a large collaboration comes back as a window listed in the response's windows; page the rest with slice: {"field": "authorships", "offset": <the next offset it names>} on the same id.
|
|
45
45
|
|
|
46
46
|
4. **Trace citations** — For each key paper, use openalex_get_citation_graph with direction "cites" to find subsequent work that cites it, and direction "cited_by" to find foundational work it references.
|
|
47
47
|
|
|
@@ -34,6 +34,7 @@ export declare const analyzeTrendsTool: import("@cyanheads/mcp-ts-core").ToolDef
|
|
|
34
34
|
key: z.ZodString;
|
|
35
35
|
key_display_name: z.ZodString;
|
|
36
36
|
count: z.ZodNumber;
|
|
37
|
+
is_unknown: z.ZodOptional<z.ZodLiteral<true>>;
|
|
37
38
|
}, z.core.$strip>>;
|
|
38
39
|
}, z.core.$strip>, readonly [{
|
|
39
40
|
readonly reason: "rate_limited";
|
|
@@ -47,7 +48,7 @@ export declare const analyzeTrendsTool: import("@cyanheads/mcp-ts-core").ToolDef
|
|
|
47
48
|
readonly code: JsonRpcErrorCode.RateLimited;
|
|
48
49
|
readonly when: "The OpenAlex daily usage budget is spent (HTTP 429).";
|
|
49
50
|
readonly retryable: false;
|
|
50
|
-
readonly recovery: "
|
|
51
|
+
readonly recovery: "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed.";
|
|
51
52
|
readonly thrownBy: "service";
|
|
52
53
|
}, {
|
|
53
54
|
readonly reason: "upstream_timeout";
|
|
@@ -96,8 +97,8 @@ export declare const analyzeTrendsTool: import("@cyanheads/mcp-ts-core").ToolDef
|
|
|
96
97
|
}, {
|
|
97
98
|
readonly reason: "upstream_ungroupable_group_by";
|
|
98
99
|
readonly code: JsonRpcErrorCode.InvalidParams;
|
|
99
|
-
readonly when: "group_by targets a raw date,
|
|
100
|
-
readonly recovery: "Group by a
|
|
100
|
+
readonly when: "group_by targets a field OpenAlex cannot aggregate — a raw date, a decimal score, a *.search operator, a field such as display_name, doi, or referenced_works, or a concept key on authors, which OpenAlex reports as an invalid OpenAlex ID (HTTP 400).";
|
|
101
|
+
readonly recovery: "Group by a field OpenAlex can aggregate, such as publication_year, type, or oa_status — call openalex_describe_fields(entity_type, \"group_by\") for the full groupable set of this entity type.";
|
|
101
102
|
readonly thrownBy: "service";
|
|
102
103
|
}, {
|
|
103
104
|
readonly reason: "upstream_invalid_params_other";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze-trends.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/analyze-trends.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":"analyze-trends.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/analyze-trends.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAWjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0W5B,CAAC"}
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
6
6
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
7
|
+
import { escapeMarkdown } from '../escape-markdown.js';
|
|
7
8
|
import { renderBudgetTrailer } from '../render-budget.js';
|
|
9
|
+
import { joinNotices } from '../response-budget.js';
|
|
8
10
|
import { getOpenAlexService } from '../../../services/openalex/openalex-service.js';
|
|
9
11
|
import { ENTITY_TYPES } from '../../../services/openalex/types.js';
|
|
12
|
+
/** Disclosure for a works group_by over `authorships.countries` (see the handler). */
|
|
13
|
+
const COUNTRIES_CAP_NOTICE = 'OpenAlex builds authorships.countries groups from only the first 100 authorships of each work, so a country that appears only past position 100 on a large collaboration is not counted. Group by authorships.institutions.country_code to count every authorship.';
|
|
10
14
|
export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
11
15
|
description: 'Aggregate OpenAlex entities into groups and count them. Use for trend analysis (group works by publication_year), distribution analysis (group by oa_status, type, country), and comparative analysis (group by institution or topic). Combine with filters to scope the analysis. Returns up to 200 groups per page — use cursor pagination for fields with many distinct values.',
|
|
12
16
|
sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/analyze-trends.tool.ts',
|
|
@@ -25,7 +29,7 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
25
29
|
code: JsonRpcErrorCode.RateLimited,
|
|
26
30
|
when: 'The OpenAlex daily usage budget is spent (HTTP 429).',
|
|
27
31
|
retryable: false,
|
|
28
|
-
recovery: '
|
|
32
|
+
recovery: "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed.",
|
|
29
33
|
thrownBy: 'service',
|
|
30
34
|
},
|
|
31
35
|
{
|
|
@@ -82,8 +86,8 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
82
86
|
{
|
|
83
87
|
reason: 'upstream_ungroupable_group_by',
|
|
84
88
|
code: JsonRpcErrorCode.InvalidParams,
|
|
85
|
-
when: 'group_by targets a raw date,
|
|
86
|
-
recovery: 'Group by a
|
|
89
|
+
when: 'group_by targets a field OpenAlex cannot aggregate — a raw date, a decimal score, a *.search operator, a field such as display_name, doi, or referenced_works, or a concept key on authors, which OpenAlex reports as an invalid OpenAlex ID (HTTP 400).',
|
|
90
|
+
recovery: 'Group by a field OpenAlex can aggregate, such as publication_year, type, or oa_status — call openalex_describe_fields(entity_type, "group_by") for the full groupable set of this entity type.',
|
|
87
91
|
thrownBy: 'service',
|
|
88
92
|
},
|
|
89
93
|
{
|
|
@@ -115,7 +119,7 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
115
119
|
group_by: z
|
|
116
120
|
.string()
|
|
117
121
|
.min(1)
|
|
118
|
-
.describe('Field to group by. Works examples: "publication_year", "type", "oa_status", "primary_topic.field.id", "authorships.institutions.country_code", "is_retracted". Authors: "last_known_institutions.country_code", "has_orcid". Sources: "type", "is_oa", "country_code". Not all fields support group_by —
|
|
122
|
+
.describe('Field to group by. Works examples: "publication_year", "type", "oa_status", "primary_topic.field.id", "authorships.institutions.country_code", "is_retracted". OpenAlex builds authorships.countries groups from only the first 100 authorships of each work; authorships.institutions.country_code counts every authorship. Authors: "last_known_institutions.country_code", "has_orcid". Sources: "type", "is_oa", "country_code". Not all fields support group_by — call openalex_describe_fields(entity_type, "group_by") for the groupable set.'),
|
|
119
123
|
filters: z
|
|
120
124
|
.record(z.string(), z.string())
|
|
121
125
|
.optional()
|
|
@@ -123,7 +127,7 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
123
127
|
include_unknown: z
|
|
124
128
|
.boolean()
|
|
125
129
|
.default(false)
|
|
126
|
-
.describe('
|
|
130
|
+
.describe('Add a group for entities with no value for the grouped field. Hidden by default. That group carries `is_unknown: true`; OpenAlex keys it -111 or -111.0 on numeric fields, "unknown" on text fields and under order "key", and an ID ending in /unknown on ID fields — a sentinel, not a measured value. The key is not a filter value: passing -111 as a filter matches a numeric range, not the entities with no value. Boolean fields have no separate group — a missing value counts as false.'),
|
|
127
131
|
per_page: z
|
|
128
132
|
.number()
|
|
129
133
|
.int()
|
|
@@ -155,9 +159,17 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
155
159
|
groups: z
|
|
156
160
|
.array(z
|
|
157
161
|
.object({
|
|
158
|
-
key: z
|
|
159
|
-
|
|
162
|
+
key: z
|
|
163
|
+
.string()
|
|
164
|
+
.describe('Group key (OpenAlex ID or raw value), exactly as OpenAlex returns it.'),
|
|
165
|
+
key_display_name: z
|
|
166
|
+
.string()
|
|
167
|
+
.describe('Human-readable group label as plain text, with HTML entities decoded and markup removed.'),
|
|
160
168
|
count: z.number().describe('Number of entities in this group.'),
|
|
169
|
+
is_unknown: z
|
|
170
|
+
.literal(true)
|
|
171
|
+
.optional()
|
|
172
|
+
.describe('Present only on the group include_unknown adds for entities with no value; its key is an OpenAlex sentinel (-111, -111.0, unknown, or an ID ending in /unknown), not a measured value.'),
|
|
161
173
|
})
|
|
162
174
|
.describe('A single aggregation group with its key, display label, and entity count.'))
|
|
163
175
|
.describe('Aggregation groups with counts.'),
|
|
@@ -175,7 +187,7 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
175
187
|
notice: z
|
|
176
188
|
.string()
|
|
177
189
|
.optional()
|
|
178
|
-
.describe('Guidance notice. Set when a first call returns no groups (recovery suggestions), when a `cursor` continuation returns none because the traversal is already finished,
|
|
190
|
+
.describe('Guidance notice. Set when a first call returns no groups (recovery suggestions), when a `cursor` continuation returns none because the traversal is already finished, when the page is full and more groups likely exist (truncation signal with narrowing advice), or when works are grouped by authorships.countries, which OpenAlex counts from only the first 100 authorships of each work. Absent otherwise.'),
|
|
179
191
|
budget: z
|
|
180
192
|
.object({
|
|
181
193
|
costUsd: z
|
|
@@ -217,12 +229,14 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
217
229
|
});
|
|
218
230
|
const echo = buildAnalyzeEcho(input);
|
|
219
231
|
ctx.enrich({ echo, totalCount: result.meta.count });
|
|
232
|
+
// `notice` is last-wins, so every sentence is collected and set once.
|
|
233
|
+
const notices = [];
|
|
220
234
|
// An empty page on a `cursor` continuation means the key-ascending traversal already
|
|
221
235
|
// enumerated every group, so there is nothing left to remove or regroup. On a first call
|
|
222
236
|
// the same shape can still be an honest zero — including the all-values-unknown case,
|
|
223
237
|
// where `count` is nonzero but every matched entity is null for the grouped field.
|
|
224
238
|
if (result.groups.length === 0) {
|
|
225
|
-
|
|
239
|
+
notices.push(input.cursor === undefined
|
|
226
240
|
? `No groups returned for ${echo}. Try removing filters or grouping by a different field.`
|
|
227
241
|
: `Pagination exhausted for ${echo} — the previous page held the last group, so this one came back empty. Stop paging rather than removing filters or regrouping.`);
|
|
228
242
|
}
|
|
@@ -232,7 +246,7 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
232
246
|
// "top by count"). The honest signal is whether OpenAlex returned a next_cursor; page-fill
|
|
233
247
|
// is irrelevant in this mode.
|
|
234
248
|
if (result.meta.next_cursor) {
|
|
235
|
-
|
|
249
|
+
notices.push(`Showing ${result.groups.length} groups in key-ascending order. Pass the returned \`next_cursor\` to continue the traversal.`);
|
|
236
250
|
}
|
|
237
251
|
}
|
|
238
252
|
else if (result.groups.length === input.per_page) {
|
|
@@ -240,8 +254,20 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
240
254
|
// exist. The omitted groups all have counts ≤ the smallest group shown, so we can bound the
|
|
241
255
|
// gap even though OpenAlex exposes no total group count.
|
|
242
256
|
const smallestCount = result.groups[result.groups.length - 1]?.count ?? 0;
|
|
243
|
-
|
|
257
|
+
notices.push(`Showing the top ${result.groups.length} groups by count. Smallest shown has count = ${smallestCount}; any omitted group has count ≤ ${smallestCount}. Narrow with \`filters\`, raise \`per_page\` (max 200), or enumerate all with \`order: "key"\`.`);
|
|
244
258
|
}
|
|
259
|
+
// OpenAlex aggregates authorships.countries from the first-100 window its list records carry,
|
|
260
|
+
// so the counts undercount large collaborations with nothing upstream saying so; every other
|
|
261
|
+
// authorships.* group_by counts all authorships. An empty page is included: a work whose first
|
|
262
|
+
// 100 authorships carry no country contributes no group. The service also accepts OpenAlex's
|
|
263
|
+
// own `:include_unknown` suffix in group_by.
|
|
264
|
+
if (input.entity_type === 'works' &&
|
|
265
|
+
input.group_by.replace(/:include_unknown$/, '') === 'authorships.countries') {
|
|
266
|
+
notices.push(COUNTRIES_CAP_NOTICE);
|
|
267
|
+
}
|
|
268
|
+
const notice = joinNotices(notices);
|
|
269
|
+
if (notice)
|
|
270
|
+
ctx.enrich.notice(notice);
|
|
245
271
|
return {
|
|
246
272
|
meta: {
|
|
247
273
|
count: result.meta.count,
|
|
@@ -262,15 +288,16 @@ export const analyzeTrendsTool = tool('openalex_analyze_trends', {
|
|
|
262
288
|
];
|
|
263
289
|
}
|
|
264
290
|
// Time-series groupings are returned upstream in count-desc — useful for "top N years"
|
|
265
|
-
// but jarring when reading a trend. Only the rendered text is reordered
|
|
266
|
-
// structuredContent stays in upstream order
|
|
267
|
-
const
|
|
268
|
-
|
|
291
|
+
// but jarring when reading a trend. Only the rendered text is reordered, with the
|
|
292
|
+
// unknown bucket after the last period; structuredContent stays in upstream order.
|
|
293
|
+
const known = result.groups.filter((g) => !g.is_unknown);
|
|
294
|
+
const renderOrder = isTimeSeriesGrouping(known)
|
|
295
|
+
? [
|
|
296
|
+
...known.sort((a, b) => a.key.localeCompare(b.key)),
|
|
297
|
+
...result.groups.filter((g) => g.is_unknown),
|
|
298
|
+
]
|
|
269
299
|
: result.groups;
|
|
270
|
-
const lines = renderOrder.map((g) => {
|
|
271
|
-
const label = g.key === g.key_display_name ? g.key_display_name : `${g.key_display_name} (${g.key})`;
|
|
272
|
-
return `${label}: ${g.count}`;
|
|
273
|
-
});
|
|
300
|
+
const lines = renderOrder.map((g) => `${renderGroupLabel(g)}: ${g.count}`);
|
|
274
301
|
const footer = result.meta.next_cursor
|
|
275
302
|
? `\n\n*More groups available — next_cursor: \`${result.meta.next_cursor}\`*`
|
|
276
303
|
: '';
|
|
@@ -286,6 +313,22 @@ const YEAR_OR_DATE_PATTERN = /^\d{4}(-\d{2}-\d{2})?$/;
|
|
|
286
313
|
function isTimeSeriesGrouping(groups) {
|
|
287
314
|
return groups.length > 1 && groups.every((g) => YEAR_OR_DATE_PATTERN.test(g.key));
|
|
288
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* The label a group line opens with. The unknown bucket is named as such — its key is a
|
|
318
|
+
* sentinel, and `-111.0: 48777` reads as a measured value. A label that begins the line is
|
|
319
|
+
* escaped for that position; the key is upstream's own and stays byte-identical unless it
|
|
320
|
+
* carries Markdown syntax.
|
|
321
|
+
*/
|
|
322
|
+
function renderGroupLabel(g) {
|
|
323
|
+
const key = escapeMarkdown(g.key);
|
|
324
|
+
if (g.is_unknown) {
|
|
325
|
+
const redundant = g.key_display_name === g.key || g.key_display_name.toLowerCase() === 'unknown';
|
|
326
|
+
const label = redundant ? '' : `, label ${escapeMarkdown(g.key_display_name)}`;
|
|
327
|
+
return `unknown (no value; key ${key}${label})`;
|
|
328
|
+
}
|
|
329
|
+
const display = escapeMarkdown(g.key_display_name, 'line-start');
|
|
330
|
+
return g.key === g.key_display_name ? display : `${display} (${key})`;
|
|
331
|
+
}
|
|
289
332
|
function buildAnalyzeEcho(input) {
|
|
290
333
|
const parts = [`entity_type=${input.entity_type}`, `group_by=${input.group_by}`];
|
|
291
334
|
if (input.filters && Object.keys(input.filters).length > 0) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze-trends.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/analyze-trends.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;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,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAE5D,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC,yBAAyB,EAAE;IAC/D,WAAW,EACT,oXAAoX;IACtX,SAAS,EACP,oHAAoH;IACtH,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,iFAAiF;YACvF,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,
|
|
1
|
+
{"version":3,"file":"analyze-trends.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/analyze-trends.tool.ts"],"names":[],"mappings":"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"}
|
|
@@ -26,6 +26,26 @@ export declare const getCitationGraphTool: import("@cyanheads/mcp-ts-core").Tool
|
|
|
26
26
|
id: z.ZodString;
|
|
27
27
|
display_name: z.ZodNullable<z.ZodString>;
|
|
28
28
|
}, z.core.$loose>>;
|
|
29
|
+
omitted: z.ZodOptional<z.ZodObject<{
|
|
30
|
+
ids: z.ZodArray<z.ZodString>;
|
|
31
|
+
next: z.ZodObject<{
|
|
32
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
33
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
}, z.core.$strip>>;
|
|
36
|
+
windows: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
37
|
+
id: z.ZodString;
|
|
38
|
+
field: z.ZodString;
|
|
39
|
+
offset: z.ZodNumber;
|
|
40
|
+
shown: z.ZodNumber;
|
|
41
|
+
total: z.ZodNumber;
|
|
42
|
+
possibly_capped: z.ZodBoolean;
|
|
43
|
+
next: z.ZodNullable<z.ZodObject<{
|
|
44
|
+
tool: z.ZodLiteral<"openalex_search_entities">;
|
|
45
|
+
arguments: z.ZodObject<{}, z.core.$loose>;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
}, z.core.$strip>>>;
|
|
48
|
+
over_budget: z.ZodOptional<z.ZodBoolean>;
|
|
29
49
|
}, z.core.$strip>, readonly [{
|
|
30
50
|
readonly reason: "rate_limited";
|
|
31
51
|
readonly code: JsonRpcErrorCode.RateLimited;
|
|
@@ -38,7 +58,7 @@ export declare const getCitationGraphTool: import("@cyanheads/mcp-ts-core").Tool
|
|
|
38
58
|
readonly code: JsonRpcErrorCode.RateLimited;
|
|
39
59
|
readonly when: "The OpenAlex daily usage budget is spent (HTTP 429).";
|
|
40
60
|
readonly retryable: false;
|
|
41
|
-
readonly recovery: "
|
|
61
|
+
readonly recovery: "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed.";
|
|
42
62
|
readonly thrownBy: "service";
|
|
43
63
|
}, {
|
|
44
64
|
readonly reason: "upstream_timeout";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"citation-graph.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/citation-graph.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":"citation-graph.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/citation-graph.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAoCjE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqW/B,CAAC"}
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
6
6
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
7
7
|
import { renderBudgetTrailer } from '../render-budget.js';
|
|
8
|
-
import {
|
|
8
|
+
import { fitRecordsToBudget, formatRecordPage, joinNotices, measurePageFrame, omittedOutputSchema, overBudgetOutputSchema, windowsOutputSchema, } from '../response-budget.js';
|
|
9
9
|
import { getOpenAlexService, translateFilterKey } from '../../../services/openalex/openalex-service.js';
|
|
10
10
|
const OPENALEX_URL_PREFIX = 'https://openalex.org/';
|
|
11
|
+
/** What `meta.count` counts, in the rendered header. */
|
|
12
|
+
const EDGE_NOUN = 'edge(s)';
|
|
11
13
|
const DIRECTIONS = ['cites', 'cited_by', 'related_to'];
|
|
12
14
|
const RESERVED_FILTER_KEYS = new Set(DIRECTIONS);
|
|
13
15
|
function buildCitationEcho(input) {
|
|
@@ -20,7 +22,7 @@ function buildCitationEcho(input) {
|
|
|
20
22
|
return parts.join(' | ');
|
|
21
23
|
}
|
|
22
24
|
export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
23
|
-
description: "Walk the citation graph one hop from a seed work. Direction picks the edge: incoming citations (`cites`), the seed's own references (`cited_by`), or OpenAlex's algorithmically-related works (`related_to`). Note: `direction` follows OpenAlex's filter convention, which inverts the common English reading — `cites` returns works that cite the seed; `cited_by` returns works the seed cites. Results use the works schema; combine with filters/sort to narrow further.",
|
|
25
|
+
description: "Walk the citation graph one hop from a seed work. Direction picks the edge: incoming citations (`cites`), the seed's own references (`cited_by`), or OpenAlex's algorithmically-related works (`related_to`). Note: `direction` follows OpenAlex's filter convention, which inverts the common English reading — `cites` returns works that cite the seed; `cited_by` returns works the seed cites. Results use the works schema; combine with filters/sort to narrow further. Responses cap at 64,000 bytes per surface unless the least a call can return is larger (`over_budget`); `omitted` and `windows` give the openalex_search_entities calls that continue a cut.",
|
|
24
26
|
sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/citation-graph.tool.ts',
|
|
25
27
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
26
28
|
errors: [
|
|
@@ -37,7 +39,7 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
37
39
|
code: JsonRpcErrorCode.RateLimited,
|
|
38
40
|
when: 'The OpenAlex daily usage budget is spent (HTTP 429).',
|
|
39
41
|
retryable: false,
|
|
40
|
-
recovery: '
|
|
42
|
+
recovery: "This server's daily OpenAlex budget is spent and refills at midnight UTC — retrying before then will not succeed.",
|
|
41
43
|
thrownBy: 'service',
|
|
42
44
|
},
|
|
43
45
|
{
|
|
@@ -138,14 +140,14 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
138
140
|
select: z
|
|
139
141
|
.array(z.string())
|
|
140
142
|
.optional()
|
|
141
|
-
.describe('OpenAlex work field names to return. Always returned: id, display_name. Defaults to the curated works select if omitted.'),
|
|
143
|
+
.describe('OpenAlex work field names to return. Always returned: id, display_name. Defaults to the curated works select if omitted. Large projections may be cut to fit the response budget (`omitted`, `windows`).'),
|
|
142
144
|
per_page: z
|
|
143
145
|
.number()
|
|
144
146
|
.int()
|
|
145
147
|
.min(1)
|
|
146
148
|
.max(100)
|
|
147
149
|
.default(25)
|
|
148
|
-
.describe('Results per page (1-100). Default 25.'),
|
|
150
|
+
.describe('Results per page (1-100). Default 25. A budget-cut page returns fewer (`omitted`).'),
|
|
149
151
|
cursor: z
|
|
150
152
|
.string()
|
|
151
153
|
.min(1)
|
|
@@ -160,11 +162,11 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
160
162
|
.describe('Total edges from seed_id in this direction (across all pages).'),
|
|
161
163
|
per_page: z
|
|
162
164
|
.number()
|
|
163
|
-
.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.'),
|
|
165
|
+
.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, as does a page the response budget cut (`omitted`).'),
|
|
164
166
|
next_cursor: z
|
|
165
167
|
.string()
|
|
166
168
|
.nullable()
|
|
167
|
-
.describe('Cursor for next page. null if no more results.'),
|
|
169
|
+
.describe('Cursor for next page. null if no more results. Continues after the full upstream page, `omitted` works included.'),
|
|
168
170
|
})
|
|
169
171
|
.describe('Result metadata including pagination.'),
|
|
170
172
|
results: z
|
|
@@ -176,9 +178,12 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
176
178
|
.nullable()
|
|
177
179
|
.describe('Work title. null when OpenAlex holds no title for the record (paratext works and other untitled entries) — use `id` to identify it.'),
|
|
178
180
|
})
|
|
179
|
-
.
|
|
181
|
+
.loose()
|
|
180
182
|
.describe('A single OpenAlex work record on the citation graph. Additional fields vary by `select`.'))
|
|
181
|
-
.describe('Works on the citation graph in this direction.'),
|
|
183
|
+
.describe('Works on the citation graph in this direction. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them. Works cut by the response budget are in `omitted`; partial or possibly capped arrays are in `windows`.'),
|
|
184
|
+
omitted: omittedOutputSchema,
|
|
185
|
+
windows: windowsOutputSchema,
|
|
186
|
+
over_budget: overBudgetOutputSchema,
|
|
182
187
|
}),
|
|
183
188
|
// Agent-facing context for the success path — the query as parsed (seed + direction),
|
|
184
189
|
// the total edge count, and recovery guidance for empty traversals. Populated via
|
|
@@ -191,7 +196,7 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
191
196
|
notice: z
|
|
192
197
|
.string()
|
|
193
198
|
.optional()
|
|
194
|
-
.describe('Guidance when no edges are returned
|
|
199
|
+
.describe('Guidance notice. Set when no edges are returned — a first call suggests verifying the seed_id, broadening filters, or trying a different direction; a `cursor` continuation says the walk is already past its last edge instead — when the response budget cut works, windowed an array, or ran over, and when a work carries exactly 100 authorships (possibly capped). Absent otherwise.'),
|
|
195
200
|
budget: z
|
|
196
201
|
.object({
|
|
197
202
|
costUsd: z
|
|
@@ -225,11 +230,7 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
225
230
|
if (reserved !== undefined) {
|
|
226
231
|
const upstreamKey = translateFilterKey('works', reserved);
|
|
227
232
|
const resolution = upstreamKey === reserved ? '' : ` — it resolves to \`${upstreamKey}\``;
|
|
228
|
-
throw ctx.fail('reserved_filter_key', `${reserved} cannot be passed in filters${resolution}, and direction reserves cites/cited_by/related_to.`, {
|
|
229
|
-
...ctx.recoveryFor('reserved_filter_key'),
|
|
230
|
-
reservedKey: reserved,
|
|
231
|
-
direction: input.direction,
|
|
232
|
-
});
|
|
233
|
+
throw ctx.fail('reserved_filter_key', `${reserved} cannot be passed in filters${resolution}, and direction reserves cites/cited_by/related_to.`, { reservedKey: reserved, direction: input.direction });
|
|
233
234
|
}
|
|
234
235
|
const service = getOpenAlexService();
|
|
235
236
|
/**
|
|
@@ -243,7 +244,7 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
243
244
|
const seedLookup = await service.search({ entityType: 'works', id: input.seed_id, select: ['id'] }, ctx);
|
|
244
245
|
const seedRecord = seedLookup.results[0];
|
|
245
246
|
if (!seedRecord?.id) {
|
|
246
|
-
throw ctx.fail('entity_not_found', `Could not resolve seed_id "${input.seed_id}" to an OpenAlex work ID.`, {
|
|
247
|
+
throw ctx.fail('entity_not_found', `Could not resolve seed_id "${input.seed_id}" to an OpenAlex work ID.`, { seedId: input.seed_id });
|
|
247
248
|
}
|
|
248
249
|
const workId = seedRecord.id.replace(OPENALEX_URL_PREFIX, '');
|
|
249
250
|
const mergedFilters = {
|
|
@@ -268,34 +269,33 @@ export const getCitationGraphTool = tool('openalex_get_citation_graph', {
|
|
|
268
269
|
ctx.enrich({ echo, totalCount: result.meta.count });
|
|
269
270
|
// An empty page on a `cursor` continuation means the walk already returned every edge it
|
|
270
271
|
// had, so the verify-the-seed advice would send the caller after a seed that is fine.
|
|
271
|
-
|
|
272
|
-
|
|
272
|
+
const emptyNotice = result.results.length > 0
|
|
273
|
+
? undefined
|
|
274
|
+
: input.cursor === undefined
|
|
273
275
|
? `No edges for ${echo}. Verify the seed_id with openalex_resolve_name, broaden filters, or try a different direction.`
|
|
274
|
-
: `Pagination exhausted for ${echo} — the previous page held the last edge, so this one came back empty. Stop paging rather than broadening filters or changing direction
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
per_page: result.meta.per_page,
|
|
280
|
-
next_cursor: result.meta.next_cursor,
|
|
281
|
-
},
|
|
282
|
-
results: result.results,
|
|
276
|
+
: `Pagination exhausted for ${echo} — the previous page held the last edge, so this one came back empty. Stop paging rather than broadening filters or changing direction.`;
|
|
277
|
+
const meta = {
|
|
278
|
+
count: result.meta.count,
|
|
279
|
+
per_page: result.meta.per_page,
|
|
280
|
+
next_cursor: result.meta.next_cursor,
|
|
283
281
|
};
|
|
282
|
+
const page = fitRecordsToBudget({
|
|
283
|
+
records: result.results,
|
|
284
|
+
entityType: 'works',
|
|
285
|
+
path: 'list',
|
|
286
|
+
select: input.select,
|
|
287
|
+
measureFrame: (disclosure, budgetNotice) => measurePageFrame({ meta, results: [], ...disclosure }, EDGE_NOUN, {
|
|
288
|
+
echo,
|
|
289
|
+
totalCount: result.meta.count,
|
|
290
|
+
totalLabel: 'Total Edges',
|
|
291
|
+
notice: joinNotices([emptyNotice, budgetNotice]),
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
const notice = joinNotices([emptyNotice, page.notice]);
|
|
295
|
+
if (notice)
|
|
296
|
+
ctx.enrich.notice(notice);
|
|
297
|
+
return { meta, results: page.results, ...page.disclosure };
|
|
284
298
|
},
|
|
285
|
-
format: (result) =>
|
|
286
|
-
const lines = [];
|
|
287
|
-
const countLabel = `${result.meta.count} edge(s) — ${result.meta.per_page} per page`;
|
|
288
|
-
const header = result.meta.next_cursor
|
|
289
|
-
? `**${countLabel}** — next cursor: \`${result.meta.next_cursor}\``
|
|
290
|
-
: `**${countLabel}**`;
|
|
291
|
-
lines.push(header);
|
|
292
|
-
if (result.results.length === 0) {
|
|
293
|
-
return [{ type: 'text', text: lines.join('\n') }];
|
|
294
|
-
}
|
|
295
|
-
for (const record of result.results) {
|
|
296
|
-
lines.push(...renderEntityRecord(record));
|
|
297
|
-
}
|
|
298
|
-
return [{ type: 'text', text: lines.join('\n') }];
|
|
299
|
-
},
|
|
299
|
+
format: (result) => formatRecordPage(result, EDGE_NOUN),
|
|
300
300
|
});
|
|
301
301
|
//# sourceMappingURL=citation-graph.tool.js.map
|