@cyanheads/pubmed-mcp-server 2.10.5 → 2.10.7
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 +4 -4
- package/CLAUDE.md +4 -4
- package/README.md +6 -2
- package/dist/mcp-server/tools/definitions/_budget.d.ts +42 -0
- package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_budget.js +49 -0
- package/dist/mcp-server/tools/definitions/_budget.js.map +1 -0
- package/dist/mcp-server/tools/definitions/_text.d.ts +27 -2
- package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_text.js +78 -2
- package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +9 -0
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +93 -10
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +17 -0
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +243 -30
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +19 -11
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +152 -52
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +31 -12
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +28 -10
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +4 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/dist/services/europe-pmc/api-client.js +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +9 -5
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +35 -8
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +15 -0
- package/dist/services/europe-pmc/types.d.ts.map +1 -1
- package/dist/services/ncbi/api-client.js +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +6 -2
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/request-queue.js +1 -1
- package/dist/services/ncbi/response-handler.d.ts +11 -0
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +24 -5
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +17 -1
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +1 -1
- package/dist/services/openalex/openalex-service.js +2 -2
- package/dist/services/unpaywall/unpaywall-service.d.ts +17 -0
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.js +51 -8
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
- package/package.json +8 -8
- package/server.json +3 -3
package/AGENTS.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Agent Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** @cyanheads/pubmed-mcp-server
|
|
4
|
-
**Version:** 2.10.
|
|
5
|
-
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.
|
|
4
|
+
**Version:** 2.10.7
|
|
5
|
+
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.7`
|
|
6
6
|
**Engines:** Bun ≥1.3.0, Node ≥24.0.0
|
|
7
7
|
|
|
8
8
|
> **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
|
|
@@ -181,7 +181,7 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
181
181
|
|
|
182
182
|
Handlers throw — the framework catches, classifies, and formats.
|
|
183
183
|
|
|
184
|
-
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint. Spread `ctx.recoveryFor('reason')` into `data` to mirror the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text); pass an explicit `recovery: { hint: '...' }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
|
|
184
|
+
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint. Spread `ctx.recoveryFor('reason')` into `data` to mirror the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text); pass an explicit `recovery: { hint: '...' }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
|
|
185
185
|
|
|
186
186
|
```ts
|
|
187
187
|
errors: [
|
|
@@ -354,7 +354,7 @@ Directory-based, grouped by minor series via the `.x` semver-wildcard convention
|
|
|
354
354
|
|
|
355
355
|
`agent-notes` is an optional free-form field consumed by agents running the `maintenance` skill downstream — adoption instructions that don't fit the human-facing sections. Omit entirely when there's nothing to say.
|
|
356
356
|
|
|
357
|
-
**Section order
|
|
357
|
+
**Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries.
|
|
358
358
|
|
|
359
359
|
**Tag annotations** render as GitHub Release bodies via `--notes-from-tag`. They must be structured markdown — never a flat comma-separated string. Subject omits the version number (GitHub prepends it). See `changelog/template.md` for the full format reference.
|
|
360
360
|
|
package/CLAUDE.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Agent Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** @cyanheads/pubmed-mcp-server
|
|
4
|
-
**Version:** 2.10.
|
|
5
|
-
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.
|
|
4
|
+
**Version:** 2.10.7
|
|
5
|
+
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.7`
|
|
6
6
|
**Engines:** Bun ≥1.3.0, Node ≥24.0.0
|
|
7
7
|
|
|
8
8
|
> **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
|
|
@@ -181,7 +181,7 @@ Handlers receive a unified `ctx` object. Key properties:
|
|
|
181
181
|
|
|
182
182
|
Handlers throw — the framework catches, classifies, and formats.
|
|
183
183
|
|
|
184
|
-
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint. Spread `ctx.recoveryFor('reason')` into `data` to mirror the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text); pass an explicit `recovery: { hint: '...' }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
|
|
184
|
+
**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint. Spread `ctx.recoveryFor('reason')` into `data` to mirror the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text); pass an explicit `recovery: { hint: '...' }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
|
|
185
185
|
|
|
186
186
|
```ts
|
|
187
187
|
errors: [
|
|
@@ -354,7 +354,7 @@ Directory-based, grouped by minor series via the `.x` semver-wildcard convention
|
|
|
354
354
|
|
|
355
355
|
`agent-notes` is an optional free-form field consumed by agents running the `maintenance` skill downstream — adoption instructions that don't fit the human-facing sections. Omit entirely when there's nothing to say.
|
|
356
356
|
|
|
357
|
-
**Section order
|
|
357
|
+
**Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries.
|
|
358
358
|
|
|
359
359
|
**Tag annotations** render as GitHub Release bodies via `--notes-from-tag`. They must be structured markdown — never a flat comma-separated string. Subject omits the version number (GitHub prepends it). See `changelog/template.md` for the full format reference.
|
|
360
360
|
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
@@ -72,6 +72,7 @@ Fetch full article metadata by PubMed IDs.
|
|
|
72
72
|
- Direct links to PubMed and PubMed Central (when available)
|
|
73
73
|
- Optional MeSH terms, grant information, and publication types
|
|
74
74
|
- Handles PubMed's inconsistent XML (structured abstracts, missing fields, varying date formats)
|
|
75
|
+
- Opt-in whole-response ceiling: `maxResponseCharacters` keeps complete article records in response order until the next one would cross it, then defers the rest whole and lists their PMIDs in `deferred.ids`. Re-call with those PMIDs to resume exactly where the response stopped — no article is split, skipped, or duplicated. Each article is measured as the JSON record it is returned as, so a ceiling under the first article returns zero articles, the full deferred list, and the size to clear
|
|
75
76
|
|
|
76
77
|
---
|
|
77
78
|
|
|
@@ -84,10 +85,12 @@ Fetch full-text articles via a three-stage chain: NCBI PMC EFetch → Europe PMC
|
|
|
84
85
|
- Europe PMC layer (enabled by default; disable with `EUROPEPMC_ENABLED=false`) recovers PMC-counterpart records that NCBI PMC EFetch missed, and resolves DOI input to PMC counterparts when one exists. EPMC's `fullTextXML` is PMC-keyed, so preprints (PPR), patents (PAT), and Agricola (AGR) are reachable via `pubmed_europepmc_search` for metadata but have no full text via this chain.
|
|
85
86
|
- Unpaywall layer (enabled by setting `UNPAYWALL_EMAIL`) resolves DOIs to legal OA copies; extracts HTML landing pages to Markdown via Defuddle or PDFs to text via unpdf
|
|
86
87
|
- Discriminated output contract — `source: "pmc"` (structured sections, regardless of whether it came from PMC or EPMC) or `source: "unpaywall"` (best-effort body + `contentFormat`: `html-markdown` or `pdf-text`)
|
|
87
|
-
- Structured unavailable reasons (`not-found`, `no-pmc-fallback-disabled`, `no-epmc-fulltext`, `no-doi`, `no-oa`, `fetch-failed`, `parse-failed`, `service-error`) so callers can retry or explain to users without parsing text
|
|
88
|
+
- Structured unavailable reasons (`not-found`, `no-pmc-fallback-disabled`, `no-epmc-fulltext`, `no-body`, `no-doi`, `no-oa`, `fetch-failed`, `parse-failed`, `service-error`) so callers can retry or explain to users without parsing text
|
|
89
|
+
- An `unavailable` entry also carries `unqueriedTiers` when the chain skipped a tier this deployment has not configured and that tier could have served the id — the search was incomplete, and a deployment with those tiers configured may still resolve it
|
|
88
90
|
- Each `unavailable` entry carries `idType` (`pmid` / `pmcid` / `doi`) and `triedTiers` — per-tier outcomes (`not-attempted`, `miss`, `no-fulltext`, `service-error`, …) in execution order, so callers can see which stage failed and why
|
|
89
91
|
- Section filtering by title (case-insensitive match, e.g. `["methods", "results"]`) and configurable max sections apply to PMC output
|
|
90
92
|
- Character budgets keep context size predictable: `maxCharacters` caps body text per article (PMC sections and subsections, or the Unpaywall body), `maxCharactersPerSection` caps a single PMC section, and `overflowMode` picks between `truncate` (fill sections in document order) and `outline` (split the budget evenly so every heading survives with an excerpt). Budgets run after the semantic filters, and a `truncation` object reports per-article and per-section character counts whenever anything was shortened
|
|
93
|
+
- `maxResponseCharacters` bounds the whole response instead of each body: every field of a returned record counts (abstract, references, metadata, body), one ledger across PMC-, Europe PMC-, and Unpaywall-served articles. Articles past the ceiling are deferred whole, with their ids — in the branch they were requested under — in `deferred.ids` for a follow-up call
|
|
91
94
|
- Up to 10 articles per request
|
|
92
95
|
|
|
93
96
|
---
|
|
@@ -136,6 +139,7 @@ Find articles related to a source article via ELink.
|
|
|
136
139
|
- Three relationship types: `similar` (content similarity), `cited_by`, `references`
|
|
137
140
|
- Results enriched with title, authors, publication date, and source via ESummary
|
|
138
141
|
- Results returned in NCBI's relevance order
|
|
142
|
+
- Falls back to Europe PMC, then OpenAlex, when NCBI cannot answer; the response names which provider served it. A request no provider can answer fails with a typed `all_providers_failed` error instead of an empty result
|
|
139
143
|
|
|
140
144
|
---
|
|
141
145
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Whole-response character budgeting shared by the retrieval
|
|
3
|
+
* tools: how many whole items fit under a character ceiling, in order. The
|
|
4
|
+
* sibling of `_text.ts`, which shortens the *inside* of one item's text —
|
|
5
|
+
* nothing here ever cuts into an item. (#99, #100)
|
|
6
|
+
* @module src/mcp-server/tools/definitions/_budget
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Characters an item contributes to the response, measured as the JSON form it
|
|
10
|
+
* is serialized into for `structuredContent` — every field the record carries,
|
|
11
|
+
* keys included. `JSON.stringify` returns `undefined` for values with no JSON
|
|
12
|
+
* form (`undefined`, a function), which contribute nothing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function serializedCharacters(value: unknown): number;
|
|
15
|
+
/** How an ordered list of items divides at a character ceiling. */
|
|
16
|
+
export interface WholeItemFit<T> {
|
|
17
|
+
/** Items withheld to stay under the ceiling, in their original order. */
|
|
18
|
+
deferred: T[];
|
|
19
|
+
/** Items that fit, in their original order. */
|
|
20
|
+
kept: T[];
|
|
21
|
+
/** Characters the kept items account for. */
|
|
22
|
+
keptCharacters: number;
|
|
23
|
+
/**
|
|
24
|
+
* Size of the next deferred item — `deferred[0]`, the one the cut stopped
|
|
25
|
+
* at. Because the cut is a prefix cut, that item is the ceiling a caller must
|
|
26
|
+
* clear to make progress: a smaller item further down the tail is unreachable
|
|
27
|
+
* until the one in front of it fits. Absent when nothing was deferred.
|
|
28
|
+
*/
|
|
29
|
+
nextDeferredCharacters?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Split an ordered list at the first item that would carry the running
|
|
33
|
+
* character total past `maxCharacters`. That item and everything after it is
|
|
34
|
+
* deferred whole; no item is ever shortened, reordered, or skipped over.
|
|
35
|
+
*
|
|
36
|
+
* The cut is a prefix cut on purpose: skipping a too-large item to fit a later
|
|
37
|
+
* smaller one would leave the caller with a non-contiguous remainder and no way
|
|
38
|
+
* to resume from the deferred list alone. Keeping the cut contiguous makes
|
|
39
|
+
* re-calling with `deferred` exactly equivalent to continuing the first call.
|
|
40
|
+
*/
|
|
41
|
+
export declare function fitWholeItems<T>(items: readonly T[], maxCharacters: number, measure?: (item: T) => number): WholeItemFit<T>;
|
|
42
|
+
//# sourceMappingURL=_budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_budget.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_budget.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAE3D;AAED,mEAAmE;AACnE,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,yEAAyE;IACzE,QAAQ,EAAE,CAAC,EAAE,CAAC;IACd,+CAA+C;IAC/C,IAAI,EAAE,CAAC,EAAE,CAAC;IACV,6CAA6C;IAC7C,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAC7B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAA6B,GAClD,YAAY,CAAC,CAAC,CAAC,CAuBjB"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Whole-response character budgeting shared by the retrieval
|
|
3
|
+
* tools: how many whole items fit under a character ceiling, in order. The
|
|
4
|
+
* sibling of `_text.ts`, which shortens the *inside* of one item's text —
|
|
5
|
+
* nothing here ever cuts into an item. (#99, #100)
|
|
6
|
+
* @module src/mcp-server/tools/definitions/_budget
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Characters an item contributes to the response, measured as the JSON form it
|
|
10
|
+
* is serialized into for `structuredContent` — every field the record carries,
|
|
11
|
+
* keys included. `JSON.stringify` returns `undefined` for values with no JSON
|
|
12
|
+
* form (`undefined`, a function), which contribute nothing.
|
|
13
|
+
*/
|
|
14
|
+
export function serializedCharacters(value) {
|
|
15
|
+
return JSON.stringify(value)?.length ?? 0;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Split an ordered list at the first item that would carry the running
|
|
19
|
+
* character total past `maxCharacters`. That item and everything after it is
|
|
20
|
+
* deferred whole; no item is ever shortened, reordered, or skipped over.
|
|
21
|
+
*
|
|
22
|
+
* The cut is a prefix cut on purpose: skipping a too-large item to fit a later
|
|
23
|
+
* smaller one would leave the caller with a non-contiguous remainder and no way
|
|
24
|
+
* to resume from the deferred list alone. Keeping the cut contiguous makes
|
|
25
|
+
* re-calling with `deferred` exactly equivalent to continuing the first call.
|
|
26
|
+
*/
|
|
27
|
+
export function fitWholeItems(items, maxCharacters, measure = serializedCharacters) {
|
|
28
|
+
const kept = [];
|
|
29
|
+
let keptCharacters = 0;
|
|
30
|
+
let cut = items.length;
|
|
31
|
+
for (const [index, item] of items.entries()) {
|
|
32
|
+
const size = measure(item);
|
|
33
|
+
if (keptCharacters + size > maxCharacters) {
|
|
34
|
+
cut = index;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
kept.push(item);
|
|
38
|
+
keptCharacters += size;
|
|
39
|
+
}
|
|
40
|
+
const deferred = items.slice(cut);
|
|
41
|
+
const [next] = deferred;
|
|
42
|
+
return {
|
|
43
|
+
kept,
|
|
44
|
+
deferred,
|
|
45
|
+
keptCharacters,
|
|
46
|
+
...(next !== undefined && { nextDeferredCharacters: measure(next) }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=_budget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_budget.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_budget.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,CAAC;AAC5C,CAAC;AAmBD;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,KAAmB,EACnB,aAAqB,EACrB,OAAO,GAAwB,oBAAoB;IAEnD,MAAM,IAAI,GAAQ,EAAE,CAAC;IACrB,IAAI,cAAc,GAAG,CAAC,CAAC;IACvB,IAAI,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC;IAEvB,KAAK,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;QAC5C,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,cAAc,GAAG,IAAI,GAAG,aAAa,EAAE,CAAC;YAC1C,GAAG,GAAG,KAAK,CAAC;YACZ,MAAM;QACR,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChB,cAAc,IAAI,IAAI,CAAC;IACzB,CAAC;IAED,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAClC,MAAM,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC;IACxB,OAAO;QACL,IAAI;QACJ,QAAQ;QACR,cAAc;QACd,GAAG,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,sBAAsB,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;KACrE,CAAC;AACJ,CAAC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Text
|
|
3
|
-
* returned text to a
|
|
2
|
+
* @fileoverview Text helpers shared by the tool definitions: the surrogate-safe
|
|
3
|
+
* character cut used to bound returned text to a budget, and the render-time
|
|
4
|
+
* Markdown escape applied to upstream strings interpolated into `format()`.
|
|
4
5
|
* @module src/mcp-server/tools/definitions/_text
|
|
5
6
|
*/
|
|
6
7
|
/**
|
|
@@ -19,4 +20,28 @@
|
|
|
19
20
|
* than assume the allowance was spent exactly. (#93)
|
|
20
21
|
*/
|
|
21
22
|
export declare function sliceCodeUnits(text: string, limit: number): string;
|
|
23
|
+
/**
|
|
24
|
+
* Escape the Markdown-significant characters in an upstream string that is
|
|
25
|
+
* about to be interpolated *inline* into a `format()` line — a `### `/`#### `
|
|
26
|
+
* heading or a `**Label:** ` line. Render-time only: the escaped form must
|
|
27
|
+
* never be written back into `structuredContent`, which is a contract over the
|
|
28
|
+
* plain-text value. (#102)
|
|
29
|
+
*
|
|
30
|
+
* Minimal by design, judged on what the reader actually sees rendered. Every
|
|
31
|
+
* Markdown construct here needs a partner delimiter to form, so a delimiter is
|
|
32
|
+
* escaped only when its partner is present: a lone `*` in `5*g`, a lone `~` in
|
|
33
|
+
* `~250`, and an intraword `_` in `TP53_mutant` are inert and stay legible.
|
|
34
|
+
* Escaping them would cost a reader of the raw text a stray backslash and buy
|
|
35
|
+
* nothing. What is escaped: a tag-shaped `<` (raw HTML and autolinks),
|
|
36
|
+
* pairable `` ` ``/`*`/`~`/`_`, every `[` and `]`, and the backslash itself,
|
|
37
|
+
* without which a trailing one in the source would neutralize the escape after
|
|
38
|
+
* it. Brackets get no pairing test: sibling label lines render as one
|
|
39
|
+
* paragraph, so a lone `[` in one field and a lone `]` in the next would still
|
|
40
|
+
* form a link.
|
|
41
|
+
*
|
|
42
|
+
* Line breaks collapse to a space. That is what keeps the value inline, and
|
|
43
|
+
* with it the reason `#`, `>`, and `-` need no escaping: they are structural
|
|
44
|
+
* only at the start of a line, and the value never reaches one.
|
|
45
|
+
*/
|
|
46
|
+
export declare function escapeMarkdownInline(text: string): string;
|
|
22
47
|
//# sourceMappingURL=_text.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"_text.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_text.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"_text.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_text.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMlE;AA2CD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAazD"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Text
|
|
3
|
-
* returned text to a
|
|
2
|
+
* @fileoverview Text helpers shared by the tool definitions: the surrogate-safe
|
|
3
|
+
* character cut used to bound returned text to a budget, and the render-time
|
|
4
|
+
* Markdown escape applied to upstream strings interpolated into `format()`.
|
|
4
5
|
* @module src/mcp-server/tools/definitions/_text
|
|
5
6
|
*/
|
|
6
7
|
const HIGH_SURROGATE_FIRST = 0xd800;
|
|
@@ -29,4 +30,79 @@ export function sliceCodeUnits(text, limit) {
|
|
|
29
30
|
const splitsPair = last >= HIGH_SURROGATE_FIRST && last <= HIGH_SURROGATE_LAST;
|
|
30
31
|
return text.slice(0, splitsPair ? limit - 1 : limit);
|
|
31
32
|
}
|
|
33
|
+
/** Any line break, including the Unicode line and paragraph separators. */
|
|
34
|
+
const LINE_BREAK_RE = /[\r\n\u2028\u2029]+/g;
|
|
35
|
+
/**
|
|
36
|
+
* A `<` that opens something a Markdown renderer will read as raw HTML or an
|
|
37
|
+
* autolink: a tag-name start, then a body carrying no further angle bracket,
|
|
38
|
+
* then a closing `>`. Deliberately the same shape as `MARKUP_TAG_RE` in
|
|
39
|
+
* `text-helpers.ts` — a `<` with no `>` after it (`n<N`) or one followed by a
|
|
40
|
+
* digit (`P<0.001`) cannot open a tag and is left alone.
|
|
41
|
+
*/
|
|
42
|
+
const HTML_TAG_OPENER_RE = /<(?=[A-Za-z/!?][^<>]*>)/g;
|
|
43
|
+
/** A character that makes an adjacent `_` intraword, where it cannot emphasize. */
|
|
44
|
+
const WORD_CHAR_RE = /[0-9A-Za-z]/;
|
|
45
|
+
function countOf(text, character) {
|
|
46
|
+
return text.split(character).length - 1;
|
|
47
|
+
}
|
|
48
|
+
function escapeEvery(text, pattern) {
|
|
49
|
+
return text.replace(pattern, (match) => `\\${match}`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Positions of the `_` characters that could open or close emphasis. CommonMark
|
|
53
|
+
* forbids intraword `_` emphasis, so an underscore flanked by alphanumerics on
|
|
54
|
+
* both sides (`TP53_mutant`) is inert and stays unescaped.
|
|
55
|
+
*/
|
|
56
|
+
function pairableUnderscores(text) {
|
|
57
|
+
const positions = new Set();
|
|
58
|
+
for (let i = text.indexOf('_'); i !== -1; i = text.indexOf('_', i + 1)) {
|
|
59
|
+
const previous = text[i - 1];
|
|
60
|
+
const next = text[i + 1];
|
|
61
|
+
const intraword = previous !== undefined &&
|
|
62
|
+
next !== undefined &&
|
|
63
|
+
WORD_CHAR_RE.test(previous) &&
|
|
64
|
+
WORD_CHAR_RE.test(next);
|
|
65
|
+
if (!intraword)
|
|
66
|
+
positions.add(i);
|
|
67
|
+
}
|
|
68
|
+
return positions;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Escape the Markdown-significant characters in an upstream string that is
|
|
72
|
+
* about to be interpolated *inline* into a `format()` line — a `### `/`#### `
|
|
73
|
+
* heading or a `**Label:** ` line. Render-time only: the escaped form must
|
|
74
|
+
* never be written back into `structuredContent`, which is a contract over the
|
|
75
|
+
* plain-text value. (#102)
|
|
76
|
+
*
|
|
77
|
+
* Minimal by design, judged on what the reader actually sees rendered. Every
|
|
78
|
+
* Markdown construct here needs a partner delimiter to form, so a delimiter is
|
|
79
|
+
* escaped only when its partner is present: a lone `*` in `5*g`, a lone `~` in
|
|
80
|
+
* `~250`, and an intraword `_` in `TP53_mutant` are inert and stay legible.
|
|
81
|
+
* Escaping them would cost a reader of the raw text a stray backslash and buy
|
|
82
|
+
* nothing. What is escaped: a tag-shaped `<` (raw HTML and autolinks),
|
|
83
|
+
* pairable `` ` ``/`*`/`~`/`_`, every `[` and `]`, and the backslash itself,
|
|
84
|
+
* without which a trailing one in the source would neutralize the escape after
|
|
85
|
+
* it. Brackets get no pairing test: sibling label lines render as one
|
|
86
|
+
* paragraph, so a lone `[` in one field and a lone `]` in the next would still
|
|
87
|
+
* form a link.
|
|
88
|
+
*
|
|
89
|
+
* Line breaks collapse to a space. That is what keeps the value inline, and
|
|
90
|
+
* with it the reason `#`, `>`, and `-` need no escaping: they are structural
|
|
91
|
+
* only at the start of a line, and the value never reaches one.
|
|
92
|
+
*/
|
|
93
|
+
export function escapeMarkdownInline(text) {
|
|
94
|
+
let escaped = text.replace(LINE_BREAK_RE, ' ').replace(/\\/g, '\\\\');
|
|
95
|
+
escaped = escaped.replace(HTML_TAG_OPENER_RE, '\\<');
|
|
96
|
+
if (countOf(text, '`') >= 2)
|
|
97
|
+
escaped = escapeEvery(escaped, /`/g);
|
|
98
|
+
escaped = escapeEvery(escaped, /[[\]]/g);
|
|
99
|
+
if (countOf(text, '*') >= 2)
|
|
100
|
+
escaped = escapeEvery(escaped, /\*/g);
|
|
101
|
+
if (countOf(text, '~') >= 2)
|
|
102
|
+
escaped = escapeEvery(escaped, /~/g);
|
|
103
|
+
const pairable = pairableUnderscores(escaped);
|
|
104
|
+
if (pairable.size < 2)
|
|
105
|
+
return escaped;
|
|
106
|
+
return escaped.replace(/_/g, (underscore, index) => pairable.has(index) ? `\\${underscore}` : underscore);
|
|
107
|
+
}
|
|
32
108
|
//# sourceMappingURL=_text.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"_text.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_text.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"_text.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_text.ts"],"names":[],"mappings":"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"}
|
|
@@ -9,6 +9,7 @@ export declare const fetchArticlesTool: import("@cyanheads/mcp-ts-core").ToolDef
|
|
|
9
9
|
pmids: z.ZodArray<z.ZodString>;
|
|
10
10
|
includeMesh: z.ZodDefault<z.ZodBoolean>;
|
|
11
11
|
includeGrants: z.ZodDefault<z.ZodBoolean>;
|
|
12
|
+
maxResponseCharacters: z.ZodOptional<z.ZodNumber>;
|
|
12
13
|
}, z.core.$strip>, z.ZodObject<{
|
|
13
14
|
articles: z.ZodArray<z.ZodObject<{
|
|
14
15
|
pmid: z.ZodOptional<z.ZodString>;
|
|
@@ -69,6 +70,13 @@ export declare const fetchArticlesTool: import("@cyanheads/mcp-ts-core").ToolDef
|
|
|
69
70
|
}, z.core.$strip>>;
|
|
70
71
|
totalReturned: z.ZodNumber;
|
|
71
72
|
unavailablePmids: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
73
|
+
deferred: z.ZodOptional<z.ZodObject<{
|
|
74
|
+
maxResponseCharacters: z.ZodNumber;
|
|
75
|
+
returnedCharacters: z.ZodNumber;
|
|
76
|
+
deferredCount: z.ZodNumber;
|
|
77
|
+
ids: z.ZodArray<z.ZodString>;
|
|
78
|
+
nextDeferredCharacters: z.ZodNumber;
|
|
79
|
+
}, z.core.$strip>>;
|
|
72
80
|
}, z.core.$strip>, readonly [{
|
|
73
81
|
readonly reason: 'queue_full';
|
|
74
82
|
readonly code: JsonRpcErrorCode.RateLimited;
|
|
@@ -106,5 +114,6 @@ export declare const fetchArticlesTool: import("@cyanheads/mcp-ts-core").ToolDef
|
|
|
106
114
|
readonly recovery: 'Retry once; if it persists, NCBI returned malformed data — try fewer PMIDs at once.';
|
|
107
115
|
}], {
|
|
108
116
|
readonly notice: z.ZodOptional<z.ZodString>;
|
|
117
|
+
readonly truncated: z.ZodOptional<z.ZodBoolean>;
|
|
109
118
|
}>;
|
|
110
119
|
//# sourceMappingURL=fetch-articles.tool.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-articles.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/fetch-articles.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"fetch-articles.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/fetch-articles.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA0IjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAWhB,yBAAyB;;mBAE3B,sEAAsE;uBAE1E,qFAAqF;;;;EAqO3F,CAAC"}
|
|
@@ -9,8 +9,10 @@ import { NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js';
|
|
|
9
9
|
import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
|
|
10
10
|
import { parseFullArticle } from '../../../services/ncbi/parsing/article-parser.js';
|
|
11
11
|
import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
|
|
12
|
+
import { fitWholeItems } from './_budget.js';
|
|
12
13
|
import { conceptMeta, EDAM_DATA_RETRIEVAL, EDAM_PUBMED_ID, SCHEMA_SCHOLARLY_ARTICLE, } from './_concepts.js';
|
|
13
14
|
import { pmidStringSchema } from './_schemas.js';
|
|
15
|
+
import { escapeMarkdownInline } from './_text.js';
|
|
14
16
|
const AuthorSchema = z
|
|
15
17
|
.object({
|
|
16
18
|
lastName: z.string().optional().describe('Last name'),
|
|
@@ -97,8 +99,27 @@ const FetchedArticleSchema = z
|
|
|
97
99
|
articleDates: z.array(ArticleDateSchema).optional().describe('Article dates'),
|
|
98
100
|
})
|
|
99
101
|
.describe('Parsed PubMed article');
|
|
102
|
+
const DeferredSchema = z
|
|
103
|
+
.object({
|
|
104
|
+
maxResponseCharacters: z
|
|
105
|
+
.number()
|
|
106
|
+
.describe('The `maxResponseCharacters` ceiling this response was budgeted against'),
|
|
107
|
+
returnedCharacters: z
|
|
108
|
+
.number()
|
|
109
|
+
.describe('Serialized characters the returned article records account for'),
|
|
110
|
+
deferredCount: z
|
|
111
|
+
.number()
|
|
112
|
+
.describe('Articles that resolved but were withheld to stay under the ceiling'),
|
|
113
|
+
ids: z
|
|
114
|
+
.array(z.string())
|
|
115
|
+
.describe('PMIDs of the deferred articles, in response order. Re-call `pubmed_fetch_articles` with these as `pmids` and the same other inputs to retrieve them. Never contains a PMID from `unavailablePmids`.'),
|
|
116
|
+
nextDeferredCharacters: z
|
|
117
|
+
.number()
|
|
118
|
+
.describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped. Raise `maxResponseCharacters` to at least this to make progress; a smaller article further down `ids` cannot be reached until this one fits.'),
|
|
119
|
+
})
|
|
120
|
+
.describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
|
|
100
121
|
export const fetchArticlesTool = tool('pubmed_fetch_articles', {
|
|
101
|
-
description: 'Fetch full article metadata by PubMed IDs. Returns detailed article information including abstract, authors, journal, MeSH terms.',
|
|
122
|
+
description: 'Fetch full article metadata by PubMed IDs. Returns detailed article information including abstract, authors, journal, MeSH terms. Set `maxResponseCharacters` to bound the whole response: articles past the ceiling are deferred whole and listed in `deferred.ids` for a follow-up call.',
|
|
102
123
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
103
124
|
_meta: conceptMeta([SCHEMA_SCHOLARLY_ARTICLE, EDAM_DATA_RETRIEVAL, EDAM_PUBMED_ID]),
|
|
104
125
|
sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/fetch-articles.tool.ts',
|
|
@@ -115,22 +136,37 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
|
|
|
115
136
|
pmids: z.array(pmidStringSchema).min(1).max(200).describe('PubMed IDs to fetch'),
|
|
116
137
|
includeMesh: z.boolean().default(true).describe('Include MeSH terms'),
|
|
117
138
|
includeGrants: z.boolean().default(false).describe('Include grant information'),
|
|
139
|
+
maxResponseCharacters: z
|
|
140
|
+
.number()
|
|
141
|
+
.int()
|
|
142
|
+
.min(1)
|
|
143
|
+
.max(1_000_000)
|
|
144
|
+
.optional()
|
|
145
|
+
.describe('Opt-in ceiling for the whole response, in characters. Each article is measured as the JSON record it is returned as — title, abstract, authors, journal, MeSH terms, grants, identifiers, every field it carries. Articles are kept in response order until the next one would cross the ceiling; that article and the rest are deferred whole (never partially populated) and listed in `deferred.ids`. Response envelope fields — counts, `unavailablePmids`, `deferred` itself — are not counted. Omit to return every resolved article.'),
|
|
118
146
|
}),
|
|
119
147
|
output: z.object({
|
|
120
148
|
articles: z.array(FetchedArticleSchema).describe('Parsed articles'),
|
|
121
|
-
totalReturned: z
|
|
149
|
+
totalReturned: z
|
|
150
|
+
.number()
|
|
151
|
+
.describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
|
|
122
152
|
unavailablePmids: z
|
|
123
153
|
.array(z.string())
|
|
124
154
|
.optional()
|
|
125
|
-
.describe('PMIDs that returned no article data'),
|
|
155
|
+
.describe('PMIDs that returned no article data. Reported in full regardless of where a `maxResponseCharacters` cutoff lands — these are misses, not deferrals, and re-requesting them returns nothing.'),
|
|
156
|
+
deferred: DeferredSchema.optional(),
|
|
126
157
|
}),
|
|
127
|
-
// Recovery guidance
|
|
128
|
-
//
|
|
158
|
+
// Recovery guidance for two cases — no articles returned at all, and articles
|
|
159
|
+
// the whole-response budget deferred (#99). Agent-facing context surfaced via
|
|
160
|
+
// ctx.enrich to both structuredContent and content[]; absent on a plain success.
|
|
129
161
|
enrichment: {
|
|
130
162
|
notice: z
|
|
131
163
|
.string()
|
|
132
164
|
.optional()
|
|
133
|
-
.describe('Optional guidance when no articles were returned — points to discovery tools. Absent on successful fetches.'),
|
|
165
|
+
.describe('Optional guidance when no articles were returned — points to discovery tools — or when `maxResponseCharacters` deferred articles, naming how to retrieve them. Absent on successful unbudgeted fetches.'),
|
|
166
|
+
truncated: z
|
|
167
|
+
.boolean()
|
|
168
|
+
.optional()
|
|
169
|
+
.describe('True when `maxResponseCharacters` withheld at least one resolved article. Absent when the response carries every article that resolved. The continuation state is in `deferred`.'),
|
|
134
170
|
},
|
|
135
171
|
async handler(input, ctx) {
|
|
136
172
|
ctx.log.info('Executing pubmed_fetch', { pmidCount: input.pmids.length });
|
|
@@ -157,17 +193,46 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
|
|
|
157
193
|
});
|
|
158
194
|
const returnedPmids = new Set(articles.map((a) => a.pmid).filter(Boolean));
|
|
159
195
|
const unavailable = input.pmids.filter((id) => !returnedPmids.has(id));
|
|
196
|
+
// Whole-response budget: fill with complete records in response order and
|
|
197
|
+
// hand the remainder back as PMIDs the caller can re-submit. Without
|
|
198
|
+
// `maxResponseCharacters` nothing is measured and the response is exactly
|
|
199
|
+
// what it was before the budget existed. (#99)
|
|
200
|
+
const ceiling = input.maxResponseCharacters;
|
|
201
|
+
const fit = ceiling === undefined ? undefined : fitWholeItems(articles, ceiling);
|
|
202
|
+
const returned = fit?.kept ?? articles;
|
|
203
|
+
const nextDeferredCharacters = fit?.nextDeferredCharacters;
|
|
204
|
+
// A record whose PMID never parsed is already reported in `unavailablePmids`
|
|
205
|
+
// and is not something a caller can re-request, so it is not deferrable —
|
|
206
|
+
// the count is the list's length so the two can never disagree.
|
|
207
|
+
const deferredIds = (fit?.deferred ?? []).map((a) => a.pmid).filter((id) => id.length > 0);
|
|
208
|
+
const deferred = ceiling !== undefined && fit && nextDeferredCharacters !== undefined
|
|
209
|
+
? {
|
|
210
|
+
maxResponseCharacters: ceiling,
|
|
211
|
+
returnedCharacters: fit.keptCharacters,
|
|
212
|
+
deferredCount: deferredIds.length,
|
|
213
|
+
ids: deferredIds,
|
|
214
|
+
nextDeferredCharacters,
|
|
215
|
+
}
|
|
216
|
+
: undefined;
|
|
160
217
|
ctx.log.info('pubmed_fetch completed', {
|
|
161
218
|
requested: input.pmids.length,
|
|
162
|
-
returned:
|
|
219
|
+
returned: returned.length,
|
|
220
|
+
...(deferred && { deferred: deferred.deferredCount }),
|
|
163
221
|
});
|
|
222
|
+
// Keyed on what resolved, not on what the budget kept: a batch emptied by a
|
|
223
|
+
// small ceiling is a budget outcome, not a batch of invalid PMIDs.
|
|
164
224
|
if (articles.length === 0) {
|
|
165
225
|
ctx.enrich.notice('No articles were returned. These PMIDs may be invalid, unpublished, or withdrawn. Try pubmed_search_articles to discover valid PMIDs.');
|
|
166
226
|
}
|
|
227
|
+
if (deferred) {
|
|
228
|
+
ctx.enrich({ truncated: true });
|
|
229
|
+
ctx.enrich.notice(buildDeferralNotice(deferred));
|
|
230
|
+
}
|
|
167
231
|
return {
|
|
168
|
-
articles,
|
|
169
|
-
totalReturned:
|
|
232
|
+
articles: returned,
|
|
233
|
+
totalReturned: returned.length,
|
|
170
234
|
...(unavailable.length > 0 && { unavailablePmids: unavailable }),
|
|
235
|
+
...(deferred && { deferred }),
|
|
171
236
|
};
|
|
172
237
|
},
|
|
173
238
|
format: (result) => {
|
|
@@ -175,8 +240,14 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
|
|
|
175
240
|
if (result.unavailablePmids?.length) {
|
|
176
241
|
lines.push(`**Unavailable PMIDs:** ${result.unavailablePmids.join(', ')}`);
|
|
177
242
|
}
|
|
243
|
+
if (result.deferred) {
|
|
244
|
+
const d = result.deferred;
|
|
245
|
+
lines.push(`**Deferred by the response budget:** ${d.deferredCount} article(s) — ${d.returnedCharacters} of ${d.maxResponseCharacters} budgeted characters returned; next deferred article ${d.nextDeferredCharacters} characters`, `Re-call \`pubmed_fetch_articles\` with these PMIDs: ${d.ids.join(', ')}`);
|
|
246
|
+
}
|
|
178
247
|
for (const a of result.articles) {
|
|
179
|
-
|
|
248
|
+
// Render-time only — `structuredContent.articles[].title` keeps the
|
|
249
|
+
// plain-text value the NCBI parser produced. (#102)
|
|
250
|
+
lines.push(`\n### ${escapeMarkdownInline(a.title ?? a.pmid ?? 'Unknown')}`);
|
|
180
251
|
if (a.authors?.length) {
|
|
181
252
|
lines.push(`\n**Authors (${a.authors.length}):**`);
|
|
182
253
|
for (const au of a.authors) {
|
|
@@ -262,6 +333,18 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
|
|
|
262
333
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
263
334
|
},
|
|
264
335
|
});
|
|
336
|
+
/**
|
|
337
|
+
* Compose the recovery notice for a response the whole-response budget bounded.
|
|
338
|
+
* Names what was spent, which PMIDs are still retrievable, and the ceiling the
|
|
339
|
+
* next call has to clear — so a caller reading only `content[]` can resume
|
|
340
|
+
* without inspecting `deferred`. (#99)
|
|
341
|
+
*/
|
|
342
|
+
function buildDeferralNotice(deferred) {
|
|
343
|
+
const spent = deferred.returnedCharacters === 0
|
|
344
|
+
? `No article fits the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
|
|
345
|
+
: `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
|
|
346
|
+
return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. Re-call pubmed_fetch_articles with those PMIDs to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
|
|
347
|
+
}
|
|
265
348
|
function formatAuthor(au) {
|
|
266
349
|
const parts = [];
|
|
267
350
|
if (au.collectiveName)
|