@cliwant/mcp-sam-gov 0.2.1 → 1.0.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.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
@@ -0,0 +1,730 @@
1
+ /**
2
+ * clinicaltrials.ts — ClinicalTrials.gov API v2 (`clinicaltrials.gov/api/v2`) —
3
+ * federally-registered clinical-research studies with LEAD-SPONSOR / COLLABORATOR
4
+ * / ORGANIZATION / FUNDING-SOURCE entity enrichment. ADR-0021. Source #21 on the
5
+ * R2 `getJson` GET port (a plain keyless GET — no key, no signup, no UA).
6
+ *
7
+ * WHAT IT ADDS: the trial-REGISTRATION axis of the research-funding entity layer
8
+ * already served by NIH RePORTER (ADR-0014) + NSF Awards (ADR-0020). The
9
+ * `leadSponsor` / `collaborators` / `organization` here are the pharma / biotech /
10
+ * university / agency entities that ALSO receive federal grants/contracts — the
11
+ * study-registration SIBLING of the grant sources, not a duplicate. On-mission =
12
+ * the ENTITY/sponsor/funding dimension (NOT the clinical minutiae).
13
+ *
14
+ * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
15
+ * (redirect:"error", a plain GET) / `driftError` / `num`·`str` (coerce.ts,
16
+ * null-never-0 / null-never-empty-string) / `withMeta`·`buildMeta` /
17
+ * `ResponseMeta.nextCursor`, and COPIES (does NOT import) the GovInfo opaque-cursor
18
+ * honesty + the fixed-host SSRF idiom into the single audited `getCT` helper [M2].
19
+ *
20
+ * ★ SSRF ([M2] — one audited fetch home): BOTH tools route through `getCT(path,
21
+ * label, params)`. The host is a compile-time literal (`CT_BASE`); every filter
22
+ * rides in a MODULE-BUILT `URLSearchParams` (each value URLSearchParams-encoded)
23
+ * assembled key-by-key from validated typed args — NO raw-query passthrough. The
24
+ * single-study path segment `nctId` is regex-validated `^NCT\d{8}$` BEFORE the
25
+ * path is built (a `../` / `%2F` / non-matching id → invalid_input, 0 fetch —
26
+ * mirror edgar `buildFramesUrl` S1/S2). A post-construction hostname/protocol
27
+ * assertion + `redirect:"error"` lock it (fail closed on any off-host 3xx).
28
+ *
29
+ * ★ THE THREE HONESTY FACTS (LIVE-verified 2026-07-12, keyless plain GET, this IP):
30
+ * 1. `totalCount` is EXACT + filter-respecting + UNCAPPED — but OMITTED unless
31
+ * `countTotal=true` is sent (default keys = [studies, nextPageToken]). So the
32
+ * module ALWAYS sends `countTotal=true`; `totalAvailable = num(totalCount)`
33
+ * (a genuine 0 → 0, NEVER null, NEVER studies.length); a missing/non-number
34
+ * totalCount on a countTotal=true call ⇒ schema_drift. (cancer=142304,
35
+ * unfiltered=593334 — an exact non-round number, no saturation cap.)
36
+ * 2. Pagination is an OPAQUE cursor (`nextPageToken`). Terminal = token ABSENT
37
+ * (BOTH genuine-empty AND a single-complete-page omit it — live-verified
38
+ * progeria total=10/returned=10/no token). `nextCursor` is passed back
39
+ * VERBATIM as the `pageToken` argument (never fabricated/derived); a bad
40
+ * token loud-fails at HTTP 400 ⇒ getJson THROWS (never a silent-empty cursor).
41
+ * offset/nextOffset are null (meaningless for a cursor). CT's terminal is
42
+ * token-absent — there is NO GovInfo-style `"*"` sentinel.
43
+ * 3. An INVALID `funderType` SILENTLY returns `totalCount:0` at HTTP 200 (NOT a
44
+ * 400 — contrast a bad overallStatus / pageToken / aggFilters-KEY, which all
45
+ * 400). So `funderType` is a FROZEN 4-value enum ([M1] re-validated IN THE
46
+ * HANDLER, not only in Zod) — a non-member ⇒ invalid_input PRE-fetch, 0
47
+ * network call, NEVER passed through to be read as a genuine empty.
48
+ *
49
+ * ★ [M1] IN-HANDLER ENUM RE-GUARD: `funderType` AND `overallStatus` are re-checked
50
+ * against the frozen CT enum arrays INLINE in the search handler (throw
51
+ * invalid_input pre-fetch, 0 fetch) — closing the funderType silent fake-empty
52
+ * even on a Zod-BYPASSING direct handler call (mirror govinfo's in-handler
53
+ * collection re-check).
54
+ *
55
+ * ★ TOKENIZATION is AND-conjunctive for query.term/query.spons/query.cond, and CT
56
+ * splits on whitespace AND a PUNCTUATION set (NOT whitespace alone) — live-verified
57
+ * 2026-07-12: `query.spons=sanofi<delim>aventis` == the whitespace count (3) for
58
+ * space + `- , / ; + & | @ # =`; `. : _ '` do NOT split. So a single-token-LOOKING
59
+ * compound like "Sanofi-Aventis" is really 'Sanofi' AND 'Aventis' (→3 vs Sanofi
60
+ * →3416 — a ~1000× silent false-negative). A multi-TOKEN value (tokenized via the
61
+ * shared tokenizeForDisclosure / DISCLOSURE_SPLIT_RE, not just whitespace) fires a
62
+ * MANDATORY `_meta` AND-note (the mirror of NSF's OR-note, but AND); a multi-token
63
+ * sponsor SUPPRESSES the
64
+ * contradictory "matches more variants" broadening note (CT NARROWED, not broadened).
65
+ *
66
+ * ★ funderType facets OVERLAP (non-exclusive: nih+fed+industry+other sum >
67
+ * registry total) — a `_meta` note forbids summing them into a partition.
68
+ *
69
+ * ★ trial ≠ federal award (EVERY response): a registration is NOT an award;
70
+ * leadSponsor.name is FREE TEXT (not a UEI) → a NOMINAL name match only.
71
+ */
72
+ import { ToolErrorCarrier } from "./errors.js";
73
+ import { getJson, driftError } from "./datasource.js";
74
+ import { num, str } from "./coerce.js";
75
+ import { tokenizeForDisclosure } from "./disclosure.js";
76
+ import { withMeta } from "./meta.js";
77
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
78
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
79
+ // `num` (clinicaltrials.num === coerce.num — a num regression fails together;
80
+ // NO local num/str in this module).
81
+ export { num };
82
+ // Re-export the shared disclosure tokenizer (single audited copy in
83
+ // ./disclosure.js — ADR-0022) so the fault suite's parity guard resolves the SAME
84
+ // function (clinicaltrials.tokenizeForDisclosure === nsf.tokenizeForDisclosure ===
85
+ // disclosure.tokenizeForDisclosure — a class regression fails both suites at once).
86
+ export { tokenizeForDisclosure } from "./disclosure.js";
87
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
88
+ const CT_HOST = "clinicaltrials.gov";
89
+ // The FIXED base literal (host + api version). All paths interpolate off this.
90
+ const CT_BASE = "https://clinicaltrials.gov/api/v2";
91
+ const CT_STUDIES_PATH = "/studies";
92
+ // HOST+path label (keyless ⇒ no token can ever appear). Surfaces in
93
+ // ToolError.upstreamEndpoint.
94
+ const CT_LABEL = "clinicaltrials:/api/v2/studies";
95
+ // ADR-0024 — the facet-counts endpoint (per-field value distribution), a SIBLING
96
+ // path on the SAME fixed host, routed through the SAME audited getCT helper. Its
97
+ // own HOST+path label (keyless ⇒ no token) so ToolError.upstreamEndpoint and the
98
+ // _meta.source distinguish the facet endpoint from the row endpoint.
99
+ const CT_STATS_FIELDS_PATH = "/stats/field/values";
100
+ const CT_STATS_FIELDS_LABEL = "clinicaltrials:/api/v2/stats/field/values";
101
+ // ─── Frozen enums (Zod source of truth + [M1] in-handler re-guard) ─
102
+ // overallStatus — the 14 values from /stats/field/values (P24). Built FROM this
103
+ // array by the Zod enum in server.ts; re-validated in the handler (a bad value
104
+ // LOUD-fails at HTTP 400 upstream, so this guard's value is mostly a clean
105
+ // pre-fetch invalid_input + parity with funderType's re-guard).
106
+ export const CT_STATUSES = [
107
+ "COMPLETED",
108
+ "UNKNOWN",
109
+ "RECRUITING",
110
+ "TERMINATED",
111
+ "NOT_YET_RECRUITING",
112
+ "ACTIVE_NOT_RECRUITING",
113
+ "WITHDRAWN",
114
+ "ENROLLING_BY_INVITATION",
115
+ "SUSPENDED",
116
+ "WITHHELD",
117
+ "NO_LONGER_AVAILABLE",
118
+ "AVAILABLE",
119
+ "APPROVED_FOR_MARKETING",
120
+ "TEMPORARILY_NOT_AVAILABLE",
121
+ ];
122
+ const CT_STATUSES_SET = new Set(CT_STATUSES);
123
+ // funderType — the 4 live-nonzero-confirmed values (P16). The other 4 (indiv /
124
+ // network / ambig / unknown) return 0 even on the whole registry (behaviorally
125
+ // IDENTICAL to the P18 silent-invalid-zero), so they are EXCLUDED. This is the
126
+ // load-bearing honesty guard: an INVALID funderType silently fake-empties at
127
+ // HTTP 200, so a non-member is invalid_input PRE-fetch, NEVER sent.
128
+ export const CT_FUNDER_TYPES = ["nih", "fed", "industry", "other"];
129
+ const CT_FUNDER_TYPES_SET = new Set(CT_FUNDER_TYPES);
130
+ // ─── Facet-counts field whitelist (ADR-0024 — the SECOND tool's Zod source of
131
+ // truth + its [ssrf] in-handler re-guard) ──────────────────────────────
132
+ // The 11 LIVE-VERIFIED (2026-07-12, keyless) `type:"ENUM"` faceteable fields on
133
+ // `/stats/field/values`, each of which returns COMPLETE (topValues.length ==
134
+ // uniqueValuesCount — max observed 14 ≪ the endpoint's hard 250-value cap) and a
135
+ // UNIFORM `{uniqueValuesCount:number, topValues:[{value,studiesCount}]}` shape.
136
+ // The Zod enum in server.ts is DERIVED from this frozen array (single source of
137
+ // truth); the handler ALSO re-checks each requested field against CT_FACET_FIELDS_SET
138
+ // INLINE (the [ssrf] re-guard — a Zod-bypassing DIRECT handler call cannot smuggle a
139
+ // raw field name; mirrors CT_FUNDER_TYPES_SET / CT_STATUSES_SET). ENUM-ONLY by design:
140
+ // a whitelisted field whose response is NOT type:"ENUM" (e.g. the BOOLEAN
141
+ // HealthyVolunteers `{trueCount,falseCount}` shape, which has NO topValues) is
142
+ // schema_drift, never a silent mis-parse (§Honesty #7).
143
+ export const CT_FACET_FIELDS = [
144
+ "OverallStatus", // 14 values — study-status distribution
145
+ "StudyType", // 3 — interventional / observational / expanded-access
146
+ "Phase", // 6 — ARRAY-valued ⇒ OVERLAP note (not a partition)
147
+ "LeadSponsorClass", // 9 — ★ the FUNDING-SOURCE distribution (B2G: NIH/FED/OTHER_GOV/…)
148
+ "Sex", // 3
149
+ "DesignAllocation", // 3
150
+ "DesignPrimaryPurpose", // 10
151
+ "DesignInterventionModel", // 5
152
+ "DesignMasking", // 5
153
+ "DesignObservationalModel", // 9
154
+ "DesignTimePerspective", // 4
155
+ ];
156
+ const CT_FACET_FIELDS_SET = new Set(CT_FACET_FIELDS);
157
+ // The ARRAY-valued members whose per-value counts OVERLAP (a study can hold several,
158
+ // e.g. PHASE1|PHASE2) so Σ counts + missing OVERSHOOTS the registry total (Phase:
159
+ // live-verified sum 477032 + missing 140698 = 617730 > 593334). The v1 whitelist's
160
+ // ONLY array member is Phase; all 10 others are scalar (sum + missing == 593334, an
161
+ // exact partition). A static Set (no /stats/size call needed) drives the not-a-
162
+ // partition note + the per-facet `overlapping` flag.
163
+ export const CT_FACET_ARRAY_FIELDS = new Set(["Phase"]);
164
+ // ─── Client-side value grammars (SSRF + injection guards) ─────────
165
+ // [M2] NCT id = exactly 8 digits (P1/P11/P12 — every observed id). Validated
166
+ // BEFORE the single-study path is built (no `/`, `?`, `..`, `%2F`, space can be
167
+ // injected — belt-and-suspenders behind the server's Zod, load-bearing for a
168
+ // direct handler call that bypasses Zod).
169
+ export const CT_NCT_RE = /^NCT\d{8}$/;
170
+ // The opaque pageToken alphabet is a base64/URL-safe SUPERSET (the real injection
171
+ // guard is URLSearchParams encoding; the regex bounds length + rejects obvious
172
+ // garbage). Mirrors GovInfo's page-mark regex MINUS the `"*"` sentinel (CT has no
173
+ // first-page sentinel — the first page is simply a call with no pageToken) AND
174
+ // MINUS a literal `%` — URLSearchParams would double-encode a `%` (`%2e`→`%252e`)
175
+ // and corrupt the cursor; CT's observed tokens are base64url (no `%`), so a stray
176
+ // `%` is rejected as invalid_input rather than silently corrupting pagination.
177
+ export const CT_TOKEN_RE = new RegExp("^[A-Za-z0-9+/=_~.,:-]{1,4096}$");
178
+ // ─── Disclosure constants (honesty obligations) ──────────────────
179
+ /** The mandatory trial≠federal-award caveat carried in EVERY response. */
180
+ const CT_TRIAL_CAVEAT = "A ClinicalTrials.gov record is the REGISTRATION of a clinical study, NOT a federal grant or contract award. leadSponsor.class / funderType (NIH/FED) indicate the study's funding-SOURCE class, and the sponsor / collaborator / organization NAMES overlap the entities in NIH RePORTER / NSF Awards / SAM / USAspending — but leadSponsor.name is a FREE-TEXT string, NOT a UEI, so any cross-reference to a federal award is a NOMINAL name match, not an authoritative entity join, and a registered trial does not imply a federal award to that sponsor.";
181
+ /** The opaque-cursor disclosure (offset/nextOffset are meaningless). */
182
+ const CT_CURSOR_NOTE = "ClinicalTrials.gov uses an opaque cursor: pagination.offset/nextOffset are not meaningful (null). Continue by passing _meta.nextCursor back as the `pageToken` argument; hasMore:false / nextCursor:null means this is the last page.";
183
+ /** funderType is a NON-EXCLUSIVE facet (values overlap; sum > registry total). */
184
+ const CT_FUNDER_OVERLAP_NOTE = "funderType is an OVERLAPPING facet — a study can have multiple funders, so the per-funderType counts MUST NOT be summed across values to reconstruct a registry total.";
185
+ /** query.spons is a fuzzy sponsor NAME search, not an exact-entity join. Emitted
186
+ * ONLY for a SINGLE-token sponsor: for a MULTI-token sponsor CT AND-splits and
187
+ * NARROWS (e.g. 'Sanofi-Aventis' → 'Sanofi' AND 'Aventis'), so the "matches more
188
+ * variants" broadening framing here would be the OPPOSITE of what happened — the
189
+ * AND-note (andTokenNote) takes precedence in that case (see searchStudies). */
190
+ const CT_SPONSOR_NOTE = "sponsor is a full-text sponsor-NAME search (query.spons), not an exact-entity equality — a single-token name also matches related name variants (e.g. 'Pfizer' matches 'Pfizer's Upjohn'), and the name is free text, NOT a UEI (nominal match only).";
191
+ /** A conservative data-currency note (not API-verifiable). */
192
+ const CT_DATA_CURRENCY_NOTE = "ClinicalTrials.gov updates registrations on a rolling basis; per-record refresh lag is not API-verifiable.";
193
+ // ─── Facet-counts disclosure constants (ADR-0024 honesty obligations) ──────
194
+ const CT_FACET_SOURCE = "clinicaltrials.gov /api/v2/stats/field/values (keyless)";
195
+ /**
196
+ * [M1] totalAvailable/returned UNIT disclosure. For the facet tool ONLY,
197
+ * `_meta.totalAvailable = Σ facet.uniqueValuesCount` and `_meta.returned =
198
+ * Σ facet.values.length` — these drive buildMeta's `returned < totalAvailable ⇒
199
+ * truncated` invariant, but they count DISTINCT FIELD VALUES, NOT studies (in
200
+ * every OTHER tool, incl. clinicaltrials_search_studies, totalAvailable is a
201
+ * study/record match count). Mandatory on every facet response so an AI never
202
+ * reads the distinct-value total as a study total.
203
+ */
204
+ const CT_FACET_UNIT_NOTE = "In this facet-counts response _meta.totalAvailable and _meta.returned count DISTINCT FIELD VALUES across the requested facet(s), NOT studies (e.g. OverallStatus+Phase ⇒ totalAvailable = 14+6 = 20 distinct values, which is NOT a study count). The per-value STUDY counts are facets[].values[].studiesCount; for a COUNT of studies use clinicaltrials_search_studies (its _meta.totalAvailable is the exact study total).";
205
+ /**
206
+ * [M2] Whole-registry scope note — NO hard-coded registry size (the registry only
207
+ * grows; freezing a total in a truthfulness string would go stale). These counts
208
+ * are ALWAYS over the ENTIRE registry and are UNfilterable (query/filter/countTotal/
209
+ * pageSize params all HTTP-400 here). Cross-links the sibling row tool for filtered
210
+ * totals.
211
+ */
212
+ const CT_FACET_SCOPE_NOTE = "These are whole-registry distribution counts — they cover the ENTIRE ClinicalTrials.gov registry and are NOT filtered by any query (the /stats/field/values endpoint rejects query.*/filter.*/countTotal/pageSize with HTTP 400). To count studies matching a specific query / sponsor / condition / status, use clinicaltrials_search_studies (its _meta.totalAvailable is the exact filtered total).";
213
+ /**
214
+ * The FACET-SCOPED trial≠federal-award caveat (EVERY facet response). Carries the
215
+ * same trial-registration ≠ federal-award substance as the row-level
216
+ * CT_TRIAL_CAVEAT, but REWORDED for a distribution output — it describes the
217
+ * LeadSponsorClass DISTRIBUTION, not a row-level leadSponsor.name free-text field.
218
+ */
219
+ const CT_FACET_TRIAL_CAVEAT = "A ClinicalTrials.gov facet count is a DISTRIBUTION over clinical-study REGISTRATIONS, NOT over federal grants or contract awards. LeadSponsorClass (NIH / FED / OTHER_GOV vs INDUSTRY / OTHER / NETWORK / …) is the study's funding-SOURCE class, NOT a count of federal awards; these classes overlap — but do NOT equal — the entities in NIH RePORTER / NSF Awards / SAM / USAspending (a nominal funding-source-class distribution, not a UEI-keyed award join). A registered trial does not imply a federal award to its sponsor.";
220
+ /** Per-facet TOP-N truncation note — the 250-cap disclosure. Never fires for the
221
+ * v1 ENUM whitelist (all ≤14 unique ≪ 250) but is load-bearing: if the whitelist
222
+ * is ever extended to a high-cardinality (STRING) field, this discloses the cap. */
223
+ function ctFacetTruncationNote(field, unique, returned) {
224
+ return `Field '${field}' has ${unique} distinct values but ClinicalTrials.gov's /stats/field/values returned only the top ${returned} by study count (a hard 250-value cap, not pageable); the remaining ${unique - returned} value(s) are OMITTED — this is NOT the full distribution.`;
225
+ }
226
+ /** Per-facet not-a-partition note — an ARRAY-valued field (Phase) whose per-value
227
+ * counts OVERLAP and MUST NOT be summed to a registry total. */
228
+ function ctFacetOverlapNote(field) {
229
+ return `'${field}' is multi-valued per study (a study can carry several values, e.g. a trial registered as PHASE1|PHASE2), so its per-value studiesCount counts OVERLAP and MUST NOT be summed to a registry total (Σ counts + missingStudiesCount OVERSHOOTS the registry size). The other facets are scalar (each study has at most one value). Always read missingStudiesCount alongside the shown buckets.`;
230
+ }
231
+ /** Per-facet high-missing interpretation note — when more studies LACK a value for
232
+ * the field than are represented across all shown buckets, the distribution covers
233
+ * a MINORITY of the registry. `missing` is this response's exact (live) count — NOT
234
+ * a frozen constant (M2). */
235
+ function ctFacetHighMissingNote(field, missing) {
236
+ return `Field '${field}' has ${missing} studies with NO value for it — MORE than the studies represented across all shown buckets — so this distribution covers a MINORITY of the registry; do NOT read the shown value counts as registry-wide (the uncounted / missing studies dominate).`;
237
+ }
238
+ // CT's Essie analyzer AND-tokenizes free-text query.spons/query.cond/query.term on
239
+ // whitespace AND the confirmed PUNCTUATION set (space + `- , / ; + & | @ # =` split
240
+ // into the AND co-occurrence; `. : _ '` do NOT — live-verified 2026-07-12), so a
241
+ // single-token-LOOKING compound like "Sanofi-Aventis" is really 'Sanofi' AND
242
+ // 'Aventis' (→3 vs Sanofi→3416 — a ~1000× silent false-negative). That precise
243
+ // class is the SHARED tokenizeForDisclosure / DISCLOSURE_SPLIT_RE (src/disclosure.js,
244
+ // ADR-0022) — byte-identical to NSF's former CT_TOKEN_SPLIT_RE / NSF_KEYWORD_SPLIT_RE
245
+ // (there OR, here AND) — so a whitespace-only detector can never miss the AND-note.
246
+ /** The AND-tokenization disclosure for a multi-TOKEN term/sponsor/condition value. */
247
+ function andTokenNote(field, tokens) {
248
+ return `ClinicalTrials.gov tokenizes a multi-word ${field} on whitespace AND punctuation (hyphen, comma, slash, semicolon, etc.) and matches it as AND — ALL tokens must co-occur; this value was split into [${tokens.join(", ")}], so a 0/small count means no study matches EVERY token, NOT that the ${field} is absent (e.g. 'Sanofi-Aventis' = 'Sanofi' AND 'Aventis' → far fewer than 'Sanofi' alone). Try a single distinctive token.`;
249
+ }
250
+ const CT_SOURCE = "clinicaltrials.gov /api/v2/studies (keyless)";
251
+ /** A string array from a mixed value, else [] (drops non-string entries). */
252
+ function strArray(x) {
253
+ if (!Array.isArray(x))
254
+ return [];
255
+ return x.map((v) => str(v)).filter((v) => v !== null);
256
+ }
257
+ /** Map a collaborators[]-style array of {name,class} entities (honest [] when absent). */
258
+ function mapEntities(x) {
259
+ if (!Array.isArray(x))
260
+ return [];
261
+ return x.map((c) => {
262
+ const it = (c ?? {});
263
+ return { name: str(it.name), class: str(it.class) };
264
+ });
265
+ }
266
+ /**
267
+ * Map ONE study (a search `studies[]` row OR the get single-study body — both
268
+ * carry `protocolSection`) → the curated entity shape. Every scalar is
269
+ * null-never-fabricated (str, null-never-empty-string — a missing/blank sponsor
270
+ * name is null, NEVER ""). collaborators/phases/conditions default to [] (an
271
+ * honest "none listed"). `briefSummary` is included ONLY for get_study.
272
+ */
273
+ function mapStudy(item, includeSummary) {
274
+ const it = (item ?? {});
275
+ const ps = (it.protocolSection ?? {});
276
+ const idm = (ps.identificationModule ?? {});
277
+ const org = (idm.organization ?? {});
278
+ const orgStudyIdInfo = (idm.orgStudyIdInfo ?? {});
279
+ const scm = (ps.sponsorCollaboratorsModule ?? {});
280
+ const lead = (scm.leadSponsor ?? {});
281
+ const statusM = (ps.statusModule ?? {});
282
+ const startStruct = (statusM.startDateStruct ?? {});
283
+ const designM = (ps.designModule ?? {});
284
+ const condM = (ps.conditionsModule ?? {});
285
+ const descM = (ps.descriptionModule ?? {});
286
+ const leadClass = str(lead.class);
287
+ const study = {
288
+ nctId: str(idm.nctId),
289
+ briefTitle: str(idm.briefTitle),
290
+ orgStudyId: str(orgStudyIdInfo.id),
291
+ organization: { name: str(org.fullName), class: str(org.class) },
292
+ leadSponsor: { name: str(lead.name), class: leadClass },
293
+ collaborators: mapEntities(scm.collaborators),
294
+ fundingClass: leadClass,
295
+ overallStatus: str(statusM.overallStatus),
296
+ startDate: str(startStruct.date),
297
+ studyType: str(designM.studyType),
298
+ phases: strArray(designM.phases),
299
+ conditions: strArray(condM.conditions),
300
+ };
301
+ if (includeSummary)
302
+ study.briefSummary = str(descM.briefSummary);
303
+ return study;
304
+ }
305
+ // ─── SSRF-guarded fetch ([M2] — one audited fetch home for BOTH tools) ──
306
+ /**
307
+ * GET one ClinicalTrials.gov v2 JSON resource. `path` is `/studies` (search) or
308
+ * `/studies/{nctId}` (get — the caller validates `nctId` `^NCT\d{8}$` BEFORE
309
+ * building the path); all query params ride in `params` (URLSearchParams,
310
+ * encoded). Builds `${CT_BASE}${path}?${params}` on the FIXED host literal, then
311
+ * asserts the CONSTRUCTED URL is `clinicaltrials.gov` over https (belt-and-
312
+ * suspenders — a future constant typo/downgrade fails closed), sets
313
+ * `redirect:"error"` (an off-host 3xx fails closed — its body is never read). NO
314
+ * headers (keyless — no key/UA required, byte-clean init). Returns the parsed
315
+ * JSON (unknown; the caller validates the response envelope + throws driftError).
316
+ */
317
+ async function getCT(path, label, params) {
318
+ const qs = params.toString();
319
+ const url = `${CT_BASE}${path}${qs ? `?${qs}` : ""}`;
320
+ const built = new URL(url);
321
+ if (built.protocol !== "https:" || built.hostname !== CT_HOST) {
322
+ throw new ToolErrorCarrier({
323
+ kind: "invalid_input",
324
+ message: `Constructed ClinicalTrials.gov URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(CT_HOST)} over https — refusing to fetch (SSRF safety).`,
325
+ retryable: false,
326
+ upstreamEndpoint: label,
327
+ });
328
+ }
329
+ return getJson(url, { label, redirect: "error" });
330
+ }
331
+ /**
332
+ * Search federally-registered clinical studies with sponsor / condition /
333
+ * location / status / funding-source filters. Each shipped filter is
334
+ * LIVE-CONFIRMED to narrow; the query is MODULE-BUILT from validated typed args
335
+ * through URLSearchParams (NO raw passthrough) with `countTotal=true` ALWAYS
336
+ * appended (§Honesty #1). Returns curated ENTITY rows (briefSummary EXCLUDED —
337
+ * payload) + honest `_meta`: the EXACT filtered total, the opaque-cursor
338
+ * continuation, the AND-tokenization disclosure, the funderType-overlap note, and
339
+ * the mandatory trial≠federal-award caveat. Disclose-not-refuse: an unscoped call
340
+ * is NOT refused — it returns the first page + the exact total + a narrowing note.
341
+ */
342
+ export async function searchStudies(args) {
343
+ const pageSize = args.pageSize ?? 20;
344
+ // ── [M1] In-handler enum re-guard (funderType AND overallStatus) — throw
345
+ // invalid_input PRE-fetch (0 network call), mirroring govinfo's in-handler
346
+ // collection re-check. This is load-bearing for a DIRECT handler call that
347
+ // bypasses Zod: an unknown funderType would build aggFilters=funderType:<v>
348
+ // and get a fabricated HTTP-200 totalCount:0 (the silent fake-empty trap). ──
349
+ if (args.funderType !== undefined &&
350
+ !CT_FUNDER_TYPES_SET.has(args.funderType)) {
351
+ throw new ToolErrorCarrier({
352
+ kind: "invalid_input",
353
+ message: `funderType ${JSON.stringify(args.funderType)} is not supported by this tool (ships: ${CT_FUNDER_TYPES.join(", ")}). An unlisted funderType silently returns totalCount:0 at HTTP 200 on ClinicalTrials.gov (indistinguishable from a genuine empty) — refused before any fetch.`,
354
+ retryable: false,
355
+ upstreamEndpoint: CT_LABEL,
356
+ });
357
+ }
358
+ if (args.overallStatus !== undefined &&
359
+ !CT_STATUSES_SET.has(args.overallStatus)) {
360
+ throw new ToolErrorCarrier({
361
+ kind: "invalid_input",
362
+ message: `overallStatus ${JSON.stringify(args.overallStatus)} is not one of the ${CT_STATUSES.length} ClinicalTrials.gov statuses (${CT_STATUSES.join(", ")}) — refused before any fetch.`,
363
+ retryable: false,
364
+ upstreamEndpoint: CT_LABEL,
365
+ });
366
+ }
367
+ // ── Belt-and-suspenders pageToken + pageSize grammars (behind the server's
368
+ // Zod). A bad token would loud-fail at HTTP 400, but reject it pre-fetch. ──
369
+ if (args.pageToken !== undefined && !CT_TOKEN_RE.test(args.pageToken)) {
370
+ throw new ToolErrorCarrier({
371
+ kind: "invalid_input",
372
+ message: `Invalid pageToken (opaque cursor) — must be a ≤4096-char base64/URL-safe token. Pass back the _meta.nextCursor from the previous page.`,
373
+ retryable: false,
374
+ upstreamEndpoint: CT_LABEL,
375
+ });
376
+ }
377
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) {
378
+ throw new ToolErrorCarrier({
379
+ kind: "invalid_input",
380
+ message: `Invalid pageSize ${JSON.stringify(pageSize)} — must be an integer in [1, 1000] (ClinicalTrials.gov clamps a larger request to 1000).`,
381
+ retryable: false,
382
+ upstreamEndpoint: CT_LABEL,
383
+ });
384
+ }
385
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
386
+ // passthrough). countTotal=true is ALWAYS appended (§Honesty #1 — omitting
387
+ // it drops the total entirely). ──
388
+ const params = new URLSearchParams();
389
+ const filtersApplied = [];
390
+ const andNotes = [];
391
+ // A helper: push a filter + detect MULTI-TOKEN AND-tokenization on CT's REAL
392
+ // delimiter set via the shared tokenizeForDisclosure (whitespace AND the confirmed
393
+ // punctuation splitters — DISCLOSURE_SPLIT_RE), so a compound like "Sanofi-Aventis"
394
+ // (= Sanofi AND Aventis) fires the mandatory AND-note instead of leaking as one
395
+ // token through a hyphen/comma/slash. Returns true iff the value split into 2+
396
+ // tokens (so the caller can suppress the contradictory sponsor-broadening note).
397
+ // andField=null ONLY for a field with no AND-note obligation. term/sponsor/
398
+ // condition AND location all ride the SAME Essie AND-tokenizer (query.locn is
399
+ // AND-split exactly like query.term/spons/cond — a multi-word location like
400
+ // "New York" is 'New' AND 'York'), so each passes its own andField label.
401
+ const pushText = (value, upstreamKey, filterLabel, andField) => {
402
+ if (value === undefined)
403
+ return false;
404
+ params.set(upstreamKey, value);
405
+ filtersApplied.push(filterLabel);
406
+ if (andField === null)
407
+ return false;
408
+ const tokens = tokenizeForDisclosure(value);
409
+ if (tokens.length > 1) {
410
+ andNotes.push(andTokenNote(andField, tokens));
411
+ return true;
412
+ }
413
+ return false;
414
+ };
415
+ pushText(args["query.term"], "query.term", "query.term", "term");
416
+ const sponsorMultiToken = pushText(args.sponsor, "query.spons", "sponsor", "sponsor");
417
+ pushText(args.condition, "query.cond", "condition", "condition");
418
+ // D2 (no-silent-filter): location (→ query.locn) is AND-tokenized identically to
419
+ // term/sponsor/condition — so a multi-word location must emit the SAME mandatory
420
+ // AND-note (was silently exempted with andField=null, hiding the narrowing).
421
+ pushText(args.location, "query.locn", "location", "location");
422
+ if (args.overallStatus !== undefined) {
423
+ params.set("filter.overallStatus", args.overallStatus);
424
+ filtersApplied.push("overallStatus");
425
+ }
426
+ if (args.funderType !== undefined) {
427
+ // The module builds the aggFilters string from the enum-validated value —
428
+ // never from raw caller text (§SSRF #3).
429
+ params.set("aggFilters", `funderType:${args.funderType}`);
430
+ filtersApplied.push("funderType");
431
+ }
432
+ params.set("countTotal", "true"); // ALWAYS — §Honesty #1
433
+ params.set("pageSize", String(pageSize));
434
+ if (args.pageToken !== undefined)
435
+ params.set("pageToken", args.pageToken);
436
+ const body = await getCT(CT_STUDIES_PATH, CT_LABEL, params);
437
+ const b = (body ?? {});
438
+ // ── Container-guarded drift (never a TypeError masking drift as
439
+ // upstream_unavailable, never a fake empty). ──
440
+ if (!Array.isArray(b.studies)) {
441
+ throw driftError(CT_LABEL, "clinicaltrials shape drift — /studies response.studies must be an array.");
442
+ }
443
+ // countTotal=true was sent, so totalCount MUST be a finite number — its absence
444
+ // is drift, NEVER a silently-null total, NEVER studies.length (§Honesty #1).
445
+ if (typeof b.totalCount !== "number" || !Number.isFinite(b.totalCount)) {
446
+ throw driftError(CT_LABEL, "clinicaltrials shape drift — /studies totalCount missing/non-number on a countTotal=true request (typeof-checked BEFORE num() so a non-number can't silently parse; NEVER fall back to studies.length).");
447
+ }
448
+ const studies = b.studies.map((s) => mapStudy(s, false));
449
+ const returned = studies.length;
450
+ const totalAvailable = num(b.totalCount); // EXACT (genuine 0 → 0)
451
+ // ── Opaque-cursor honesty (§Honesty #2). Terminal = token ABSENT. The token is
452
+ // surfaced VERBATIM (never fabricated/derived). Phantom-empty guard: 0
453
+ // studies WITH a token ⇒ terminal (never advertise a continuation into an
454
+ // empty cursor loop). ──
455
+ const rawNext = b.nextPageToken;
456
+ const nextToken = typeof rawNext === "string" && rawNext.length > 0 ? rawNext : null;
457
+ let hasMore;
458
+ let nextCursor;
459
+ if (returned === 0 && nextToken !== null) {
460
+ hasMore = false;
461
+ nextCursor = null;
462
+ }
463
+ else {
464
+ hasMore = nextToken !== null;
465
+ nextCursor = nextToken;
466
+ }
467
+ // ── Notes: the mandatory caveat + cursor + data-currency always; the
468
+ // conditional facet/tokenization disclosures; the unscoped recommendation. ──
469
+ const notes = [CT_TRIAL_CAVEAT, CT_CURSOR_NOTE];
470
+ notes.push(...andNotes);
471
+ // Emit the sponsor-broadening note ONLY for a SINGLE-token sponsor. For a
472
+ // MULTI-token sponsor CT AND-split and NARROWED (the andNotes AND-note fired),
473
+ // so the "matches more variants" framing would AFFIRMATIVELY MISLEAD (the
474
+ // opposite of what happened) — the AND-note is what the caller must see.
475
+ if (args.sponsor !== undefined && !sponsorMultiToken)
476
+ notes.push(CT_SPONSOR_NOTE);
477
+ if (args.funderType !== undefined)
478
+ notes.push(CT_FUNDER_OVERLAP_NOTE);
479
+ if (filtersApplied.length === 0) {
480
+ notes.push("No filters were applied — this is an unscoped query over the WHOLE ClinicalTrials.gov registry (~593k studies). Add a filter (query.term, sponsor, condition, location, overallStatus, funderType) for a meaningful scoped result set.");
481
+ }
482
+ notes.push(CT_DATA_CURRENCY_NOTE);
483
+ return withMeta({ studies }, {
484
+ source: CT_SOURCE,
485
+ keylessMode: true,
486
+ returned,
487
+ totalAvailable,
488
+ // complete is DERIVED by buildMeta (never forced): a first-page call whose
489
+ // returned === totalAvailable with no token ⇒ complete:true; a continuation
490
+ // page (or any page with returned < total / a token) ⇒ complete:false.
491
+ filtersApplied,
492
+ filtersDropped: [],
493
+ fieldsUnavailable: [
494
+ "briefSummary (search rows omit it — use clinicaltrials_get_study)",
495
+ ],
496
+ // Cursor page: offset/nextOffset null (no numeric offset); continuation is
497
+ // nextCursor (the opaque nextPageToken, passed back verbatim as pageToken).
498
+ pagination: { offset: null, limit: pageSize, hasMore, nextOffset: null },
499
+ nextCursor,
500
+ notes,
501
+ });
502
+ }
503
+ // ─── Tool 2: clinicaltrials_get_study ─────────────────────────────
504
+ /**
505
+ * Fetch ONE study by its NCT id; returns the fuller single record (entity fields
506
+ * + briefSummary). [M2] `nctId` is regex-validated `^NCT\d{8}$` BEFORE the path is
507
+ * built (a `../` / non-matching id ⇒ invalid_input, 0 fetch). A 404 (nonexistent
508
+ * NCT id) ⇒ honest found:false (never a fabricated study).
509
+ */
510
+ export async function getStudy(args) {
511
+ const nctId = args.nctId;
512
+ // [M2] Validate BEFORE building the path (belt-and-suspenders behind the
513
+ // server's Zod — load-bearing for a direct call; a traversal never reaches the
514
+ // path). 0 fetch on a mismatch.
515
+ if (!CT_NCT_RE.test(nctId)) {
516
+ throw new ToolErrorCarrier({
517
+ kind: "invalid_input",
518
+ message: `Invalid nctId ${JSON.stringify(nctId)} — expected an NCT id of the form NCT followed by exactly 8 digits (e.g. NCT02403869). Refused before any fetch (path-segment injection guard).`,
519
+ retryable: false,
520
+ upstreamEndpoint: CT_LABEL,
521
+ });
522
+ }
523
+ const notes = [CT_TRIAL_CAVEAT, CT_DATA_CURRENCY_NOTE];
524
+ let body;
525
+ try {
526
+ body = await getCT(`${CT_STUDIES_PATH}/${nctId}`, CT_LABEL, new URLSearchParams());
527
+ }
528
+ catch (e) {
529
+ // A nonexistent NCT id ⇒ HTTP 404 ⇒ getJson throws not_found ⇒ honest
530
+ // found:false (a definitive answer, never a fabricated study).
531
+ if (e instanceof ToolErrorCarrier && e.toolError.kind === "not_found") {
532
+ return withMeta({ found: false, nctId, study: null }, {
533
+ source: CT_SOURCE,
534
+ keylessMode: true,
535
+ returned: 0,
536
+ totalAvailable: 0,
537
+ complete: true,
538
+ filtersApplied: ["nctId"],
539
+ filtersDropped: [],
540
+ fieldsUnavailable: [],
541
+ notes: [
542
+ `No ClinicalTrials.gov study found for nctId ${JSON.stringify(nctId)} (HTTP 404) — the id does not exist. Not fabricated.`,
543
+ ...notes,
544
+ ],
545
+ });
546
+ }
547
+ throw e;
548
+ }
549
+ // A valid single-study 200 carries protocolSection at the TOP level (P11). Its
550
+ // absence is drift (never a fabricated all-null record).
551
+ const bo = (body ?? {});
552
+ if (bo.protocolSection === null ||
553
+ typeof bo.protocolSection !== "object" ||
554
+ Array.isArray(bo.protocolSection)) {
555
+ throw driftError(CT_LABEL, "clinicaltrials shape drift — /studies/{nctId} response is missing a protocolSection object.");
556
+ }
557
+ const study = mapStudy(body, true); // FULL record incl. briefSummary
558
+ return withMeta({ found: true, nctId, study }, {
559
+ source: CT_SOURCE,
560
+ keylessMode: true,
561
+ returned: 1,
562
+ totalAvailable: 1,
563
+ complete: true,
564
+ filtersApplied: ["nctId"],
565
+ filtersDropped: [],
566
+ fieldsUnavailable: [],
567
+ notes,
568
+ });
569
+ }
570
+ /**
571
+ * Map ONE `/stats/field/values` facet element → the curated facet shape (a FRESH
572
+ * mapper — does NOT reuse mapStudy/strArray/mapEntities, which are row-shaped).
573
+ * ENUM-ONLY drift guard (§Honesty #7): the element MUST be `type:"ENUM"` with an
574
+ * ARRAY `topValues` and a NUMBER `uniqueValuesCount` — a BOOLEAN
575
+ * (`{trueCount,falseCount}`, no topValues) / STRING / re-typed shape for a
576
+ * whitelisted field is schema_drift, NEVER read as an empty distribution. Each
577
+ * `studiesCount` is typeof-checked to a finite NUMBER BEFORE `num()` (mirrors the
578
+ * search tool's totalCount guard) so a non-number can NEVER silently parse or
579
+ * coerce-to-0 — an EXACT count or drift, never a fabricated 0.
580
+ */
581
+ function mapFacet(requested, item) {
582
+ const o = (item ?? {});
583
+ if (o.type !== "ENUM") {
584
+ throw driftError(CT_STATS_FIELDS_LABEL, `clinicaltrials facet shape drift — field '${requested}' returned type ${JSON.stringify(o.type)} (expected "ENUM"). A non-ENUM shape (e.g. a BOOLEAN {trueCount,falseCount} with NO topValues) must NEVER be read as an empty distribution.`);
585
+ }
586
+ if (!Array.isArray(o.topValues)) {
587
+ throw driftError(CT_STATS_FIELDS_LABEL, `clinicaltrials facet shape drift — field '${requested}' topValues is not an array (container-guarded — a TypeError must never mask drift as upstream_unavailable, never a fake empty).`);
588
+ }
589
+ if (typeof o.uniqueValuesCount !== "number" || !Number.isFinite(o.uniqueValuesCount)) {
590
+ throw driftError(CT_STATS_FIELDS_LABEL, `clinicaltrials facet shape drift — field '${requested}' uniqueValuesCount is missing/non-number (typeof-checked BEFORE num() so a string can't silently parse).`);
591
+ }
592
+ const values = o.topValues.map((v) => {
593
+ const it = (v ?? {});
594
+ if (typeof it.studiesCount !== "number" || !Number.isFinite(it.studiesCount)) {
595
+ throw driftError(CT_STATS_FIELDS_LABEL, `clinicaltrials facet shape drift — a topValues[].studiesCount for field '${requested}' is missing/non-number (typeof-checked BEFORE num() so a non-number can NEVER be silently coerced to 0 — a per-value count is EXACT or it is drift).`);
596
+ }
597
+ return { value: str(it.value), studiesCount: num(it.studiesCount) };
598
+ });
599
+ const uniqueValuesCount = num(o.uniqueValuesCount);
600
+ const returned = values.length;
601
+ return {
602
+ field: requested,
603
+ fieldPath: str(o.field),
604
+ valueType: str(o.type),
605
+ uniqueValuesCount,
606
+ missingStudiesCount: num(o.missingStudiesCount),
607
+ returned,
608
+ // The universal truncation invariant: returned < unique ⇒ truncated (rolled up
609
+ // into buildMeta via the response-level returned/totalAvailable roll-up too).
610
+ truncated: returned < uniqueValuesCount,
611
+ overlapping: CT_FACET_ARRAY_FIELDS.has(requested),
612
+ values,
613
+ };
614
+ }
615
+ /**
616
+ * Aggregate / statistical view: EXACT per-value study counts over the WHOLE
617
+ * ClinicalTrials.gov registry for one or more whitelisted ENUM fields
618
+ * (studies-by-OverallStatus / by-Phase / by-LeadSponsorClass = the funding-source
619
+ * distribution / …). The aggregate SIBLING of clinicaltrials_search_studies (which
620
+ * gives the exact FILTERED total for a query) — this gives the exact WHOLE-REGISTRY
621
+ * distribution across a field's values. Reuses the shipped getCT verbatim (one new
622
+ * path constant), coerce.num/str, buildMeta/withMeta — ZERO new fetch/coerce/error/
623
+ * meta code.
624
+ */
625
+ export async function facetCounts(args) {
626
+ // ── [ssrf] In-handler field RE-GUARD (load-bearing for a Zod-BYPASSING direct
627
+ // call): re-check EACH requested field against the frozen CT_FACET_FIELDS_SET
628
+ // INLINE (mirror CT_FUNDER_TYPES_SET / CT_STATUSES_SET) BEFORE building params,
629
+ // and dedupe (preserving order). A non-member ⇒ invalid_input PRE-fetch, 0
630
+ // network call — NO raw field-name ever reaches the URL. ──
631
+ const requested = args.fields ?? [];
632
+ const seen = new Set();
633
+ const fields = [];
634
+ for (const f of requested) {
635
+ if (!CT_FACET_FIELDS_SET.has(f)) {
636
+ throw new ToolErrorCarrier({
637
+ kind: "invalid_input",
638
+ message: `field ${JSON.stringify(f)} is not a supported facet (ships: ${CT_FACET_FIELDS.join(", ")}). An unlisted field is NOT asserted upstream-invalid (it may be a real STRING/BOOLEAN field this tool deliberately does not whitelist) — refused before any fetch (SSRF + ENUM-shape-stability guard).`,
639
+ retryable: false,
640
+ upstreamEndpoint: CT_STATS_FIELDS_LABEL,
641
+ });
642
+ }
643
+ if (!seen.has(f)) {
644
+ seen.add(f);
645
+ fields.push(f);
646
+ }
647
+ }
648
+ if (fields.length === 0) {
649
+ throw new ToolErrorCarrier({
650
+ kind: "invalid_input",
651
+ message: `at least one facet field is required (ships: ${CT_FACET_FIELDS.join(", ")}).`,
652
+ retryable: false,
653
+ upstreamEndpoint: CT_STATS_FIELDS_LABEL,
654
+ });
655
+ }
656
+ // ── Build the query from the enum-validated array (SSRF: module-built, comma-
657
+ // joined; each element is a frozen enum member — no raw passthrough). The ONLY
658
+ // query key is `fields`; no filter/scope/page key is EVER sent (they 400). ──
659
+ const params = new URLSearchParams();
660
+ params.set("fields", fields.join(","));
661
+ const body = await getCT(CT_STATS_FIELDS_PATH, CT_STATS_FIELDS_LABEL, params);
662
+ // The 200 body is a TOP-LEVEL ARRAY (one object per requested field). Its absence
663
+ // is drift, NEVER a fake-empty distribution. A 404 (a whitelisted field missing
664
+ // upstream = whitelist drift) / 400 / 5xx is THROWN by getJson (never caught).
665
+ if (!Array.isArray(body)) {
666
+ throw driftError(CT_STATS_FIELDS_LABEL, "clinicaltrials facet shape drift — /stats/field/values response must be a top-level array (one object per requested field).");
667
+ }
668
+ // Match each requested field to its response element by the echoed `piece`
669
+ // (robust to element ordering); a whitelisted field absent from the response is
670
+ // whitelist drift ⇒ driftError, never a silent empty.
671
+ const byPiece = new Map();
672
+ for (const el of body) {
673
+ const p = (el ?? {});
674
+ if (typeof p.piece === "string" && !byPiece.has(p.piece))
675
+ byPiece.set(p.piece, el);
676
+ }
677
+ const facets = [];
678
+ for (const f of fields) {
679
+ const el = byPiece.get(f);
680
+ if (el === undefined) {
681
+ throw driftError(CT_STATS_FIELDS_LABEL, `clinicaltrials facet shape drift — requested field '${f}' is absent from the response (a whitelisted field must always be echoed; its absence signals the whitelist drifted from upstream — NEVER read as an empty distribution).`);
682
+ }
683
+ facets.push(mapFacet(f, el));
684
+ }
685
+ // ── [M1] Response-level roll-up: returned = Σ values.length, totalAvailable =
686
+ // Σ uniqueValuesCount (DISTINCT VALUES, not studies — the unit note discloses
687
+ // it). buildMeta's `returned < totalAvailable ⇒ truncated:true / complete:false`
688
+ // auto-derives truncation the instant ANY facet is capped; for the v1 ENUM
689
+ // whitelist returned == totalAvailable ⇒ complete:true, truncated:false. ──
690
+ const returned = facets.reduce((a, ff) => a + ff.values.length, 0);
691
+ const totalAvailable = facets.reduce((a, ff) => a + (ff.uniqueValuesCount ?? 0), 0);
692
+ // ── Notes: the mandatory unit (M1) + scope (M2) always; per-facet truncation /
693
+ // overlap / high-missing conditionals; the facet-scoped trial≠award caveat +
694
+ // data-currency always. ──
695
+ const notes = [CT_FACET_UNIT_NOTE, CT_FACET_SCOPE_NOTE];
696
+ for (const ff of facets) {
697
+ if (ff.truncated && ff.uniqueValuesCount !== null) {
698
+ notes.push(ctFacetTruncationNote(ff.field, ff.uniqueValuesCount, ff.returned));
699
+ }
700
+ }
701
+ for (const ff of facets) {
702
+ if (ff.overlapping)
703
+ notes.push(ctFacetOverlapNote(ff.field));
704
+ }
705
+ for (const ff of facets) {
706
+ const missing = ff.missingStudiesCount;
707
+ const shown = ff.values.reduce((a, v) => a + (v.studiesCount ?? 0), 0);
708
+ // Denominator-free (no frozen registry total, M2): the shown buckets cover a
709
+ // MINORITY when more studies lack a value than are represented across them.
710
+ if (missing !== null && missing > shown) {
711
+ notes.push(ctFacetHighMissingNote(ff.field, missing));
712
+ }
713
+ }
714
+ notes.push(CT_FACET_TRIAL_CAVEAT, CT_DATA_CURRENCY_NOTE);
715
+ return withMeta({ facets }, {
716
+ source: CT_FACET_SOURCE,
717
+ keylessMode: true,
718
+ returned,
719
+ totalAvailable,
720
+ // complete/truncated DERIVED by buildMeta from returned/totalAvailable (never
721
+ // forced): v1 ENUM ⇒ complete:true; a future capped facet ⇒ complete:false.
722
+ filtersApplied: [],
723
+ filtersDropped: [],
724
+ fieldsUnavailable: [],
725
+ // NO pagination object — /stats/field/values is un-paged (pageSize 400s);
726
+ // leave `pagination` undefined (NOT {hasMore:false}).
727
+ notes,
728
+ });
729
+ }
730
+ //# sourceMappingURL=clinicaltrials.js.map