@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.
Files changed (61) hide show
  1. package/AGENTS.md +4 -4
  2. package/CLAUDE.md +4 -4
  3. package/README.md +6 -2
  4. package/dist/mcp-server/tools/definitions/_budget.d.ts +42 -0
  5. package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -0
  6. package/dist/mcp-server/tools/definitions/_budget.js +49 -0
  7. package/dist/mcp-server/tools/definitions/_budget.js.map +1 -0
  8. package/dist/mcp-server/tools/definitions/_text.d.ts +27 -2
  9. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/_text.js +78 -2
  11. package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +9 -0
  13. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +93 -10
  15. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +17 -0
  17. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +243 -30
  19. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +19 -11
  21. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/find-related.tool.js +152 -52
  23. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -1
  26. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +31 -12
  29. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +28 -10
  32. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/search-articles.tool.js +4 -1
  35. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  36. package/dist/services/europe-pmc/api-client.js +1 -1
  37. package/dist/services/europe-pmc/europe-pmc-service.d.ts +9 -5
  38. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  39. package/dist/services/europe-pmc/europe-pmc-service.js +35 -8
  40. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  41. package/dist/services/europe-pmc/types.d.ts +15 -0
  42. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  43. package/dist/services/ncbi/api-client.js +1 -1
  44. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  45. package/dist/services/ncbi/ncbi-service.js +6 -2
  46. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  47. package/dist/services/ncbi/request-queue.js +1 -1
  48. package/dist/services/ncbi/response-handler.d.ts +11 -0
  49. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  50. package/dist/services/ncbi/response-handler.js +24 -5
  51. package/dist/services/ncbi/response-handler.js.map +1 -1
  52. package/dist/services/ncbi/types.d.ts +17 -1
  53. package/dist/services/ncbi/types.d.ts.map +1 -1
  54. package/dist/services/openalex/api-client.js +1 -1
  55. package/dist/services/openalex/openalex-service.js +2 -2
  56. package/dist/services/unpaywall/unpaywall-service.d.ts +17 -0
  57. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  58. package/dist/services/unpaywall/unpaywall-service.js +51 -8
  59. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  60. package/package.json +8 -8
  61. 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
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.5`
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** (Keep a Changelog): Added, Changed, Deprecated, Removed, Fixed, Security. Include only sections with entries.
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
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.5`
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** (Keep a Changelog): Added, Changed, Deprecated, Removed, Fixed, Security. Include only sections with entries.
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
- [![Version](https://img.shields.io/badge/Version-2.10.5-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pubmed-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
12
+ [![Version](https://img.shields.io/badge/Version-2.10.7-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pubmed-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](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-cutting helper shared by the tool definitions that bound
3
- * returned text to a character budget.
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;;;;GAIG;AAKH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMlE"}
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-cutting helper shared by the tool definitions that bound
3
- * returned text to a character budget.
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;;;;GAIG;AAEH,MAAM,oBAAoB,GAAG,MAAM,CAAC;AACpC,MAAM,mBAAmB,GAAG,MAAM,CAAC;AAEnC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,KAAa;IACxD,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IAC1B,IAAI,KAAK,IAAI,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IACxC,MAAM,UAAU,GAAG,IAAI,IAAI,oBAAoB,IAAI,IAAI,IAAI,mBAAmB,CAAC;IAC/E,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;AACvD,CAAC"}
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;AA8GjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAWhB,yBAAyB;;mBAE3B,sEAAsE;uBAE1E,qFAAqF;;;EAsK3F,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.number().describe('Number of articles returned'),
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 when no articles are returned agent-facing context, surfaced via
128
- // ctx.enrich.notice() to both structuredContent and content[]; absent on success.
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: articles.length,
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: articles.length,
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
- lines.push(`\n### ${a.title ?? a.pmid ?? 'Unknown'}`);
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)