@cliwant/mcp-sam-gov 0.3.0 → 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 +226 -64
  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 +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  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 +74 -1
  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 +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  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 +419 -3
  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 +111 -6
  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 +3889 -296
  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 +1629 -201
  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 +5 -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 +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  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 +512 -3
  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 +139 -7
  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 +4551 -328
  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 +1946 -214
  228. package/src/usitc.ts +420 -0
@@ -0,0 +1,923 @@
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
+
73
+ import { ToolErrorCarrier } from "./errors.js";
74
+ import { getJson, driftError } from "./datasource.js";
75
+ import { num, str } from "./coerce.js";
76
+ import { tokenizeForDisclosure } from "./disclosure.js";
77
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
78
+
79
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
80
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
81
+ // `num` (clinicaltrials.num === coerce.num — a num regression fails together;
82
+ // NO local num/str in this module).
83
+ export { num };
84
+
85
+ // Re-export the shared disclosure tokenizer (single audited copy in
86
+ // ./disclosure.js — ADR-0022) so the fault suite's parity guard resolves the SAME
87
+ // function (clinicaltrials.tokenizeForDisclosure === nsf.tokenizeForDisclosure ===
88
+ // disclosure.tokenizeForDisclosure — a class regression fails both suites at once).
89
+ export { tokenizeForDisclosure } from "./disclosure.js";
90
+
91
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
92
+ const CT_HOST = "clinicaltrials.gov";
93
+ // The FIXED base literal (host + api version). All paths interpolate off this.
94
+ const CT_BASE = "https://clinicaltrials.gov/api/v2";
95
+ const CT_STUDIES_PATH = "/studies";
96
+ // HOST+path label (keyless ⇒ no token can ever appear). Surfaces in
97
+ // ToolError.upstreamEndpoint.
98
+ const CT_LABEL = "clinicaltrials:/api/v2/studies";
99
+ // ADR-0024 — the facet-counts endpoint (per-field value distribution), a SIBLING
100
+ // path on the SAME fixed host, routed through the SAME audited getCT helper. Its
101
+ // own HOST+path label (keyless ⇒ no token) so ToolError.upstreamEndpoint and the
102
+ // _meta.source distinguish the facet endpoint from the row endpoint.
103
+ const CT_STATS_FIELDS_PATH = "/stats/field/values";
104
+ const CT_STATS_FIELDS_LABEL = "clinicaltrials:/api/v2/stats/field/values";
105
+
106
+ // ─── Frozen enums (Zod source of truth + [M1] in-handler re-guard) ─
107
+ // overallStatus — the 14 values from /stats/field/values (P24). Built FROM this
108
+ // array by the Zod enum in server.ts; re-validated in the handler (a bad value
109
+ // LOUD-fails at HTTP 400 upstream, so this guard's value is mostly a clean
110
+ // pre-fetch invalid_input + parity with funderType's re-guard).
111
+ export const CT_STATUSES = [
112
+ "COMPLETED",
113
+ "UNKNOWN",
114
+ "RECRUITING",
115
+ "TERMINATED",
116
+ "NOT_YET_RECRUITING",
117
+ "ACTIVE_NOT_RECRUITING",
118
+ "WITHDRAWN",
119
+ "ENROLLING_BY_INVITATION",
120
+ "SUSPENDED",
121
+ "WITHHELD",
122
+ "NO_LONGER_AVAILABLE",
123
+ "AVAILABLE",
124
+ "APPROVED_FOR_MARKETING",
125
+ "TEMPORARILY_NOT_AVAILABLE",
126
+ ] as const;
127
+ export type CtStatus = (typeof CT_STATUSES)[number];
128
+ const CT_STATUSES_SET: ReadonlySet<string> = new Set(CT_STATUSES);
129
+
130
+ // funderType — the 4 live-nonzero-confirmed values (P16). The other 4 (indiv /
131
+ // network / ambig / unknown) return 0 even on the whole registry (behaviorally
132
+ // IDENTICAL to the P18 silent-invalid-zero), so they are EXCLUDED. This is the
133
+ // load-bearing honesty guard: an INVALID funderType silently fake-empties at
134
+ // HTTP 200, so a non-member is invalid_input PRE-fetch, NEVER sent.
135
+ export const CT_FUNDER_TYPES = ["nih", "fed", "industry", "other"] as const;
136
+ export type CtFunderType = (typeof CT_FUNDER_TYPES)[number];
137
+ const CT_FUNDER_TYPES_SET: ReadonlySet<string> = new Set(CT_FUNDER_TYPES);
138
+
139
+ // ─── Facet-counts field whitelist (ADR-0024 — the SECOND tool's Zod source of
140
+ // truth + its [ssrf] in-handler re-guard) ──────────────────────────────
141
+ // The 11 LIVE-VERIFIED (2026-07-12, keyless) `type:"ENUM"` faceteable fields on
142
+ // `/stats/field/values`, each of which returns COMPLETE (topValues.length ==
143
+ // uniqueValuesCount — max observed 14 ≪ the endpoint's hard 250-value cap) and a
144
+ // UNIFORM `{uniqueValuesCount:number, topValues:[{value,studiesCount}]}` shape.
145
+ // The Zod enum in server.ts is DERIVED from this frozen array (single source of
146
+ // truth); the handler ALSO re-checks each requested field against CT_FACET_FIELDS_SET
147
+ // INLINE (the [ssrf] re-guard — a Zod-bypassing DIRECT handler call cannot smuggle a
148
+ // raw field name; mirrors CT_FUNDER_TYPES_SET / CT_STATUSES_SET). ENUM-ONLY by design:
149
+ // a whitelisted field whose response is NOT type:"ENUM" (e.g. the BOOLEAN
150
+ // HealthyVolunteers `{trueCount,falseCount}` shape, which has NO topValues) is
151
+ // schema_drift, never a silent mis-parse (§Honesty #7).
152
+ export const CT_FACET_FIELDS = [
153
+ "OverallStatus", // 14 values — study-status distribution
154
+ "StudyType", // 3 — interventional / observational / expanded-access
155
+ "Phase", // 6 — ARRAY-valued ⇒ OVERLAP note (not a partition)
156
+ "LeadSponsorClass", // 9 — ★ the FUNDING-SOURCE distribution (B2G: NIH/FED/OTHER_GOV/…)
157
+ "Sex", // 3
158
+ "DesignAllocation", // 3
159
+ "DesignPrimaryPurpose", // 10
160
+ "DesignInterventionModel", // 5
161
+ "DesignMasking", // 5
162
+ "DesignObservationalModel", // 9
163
+ "DesignTimePerspective", // 4
164
+ ] as const;
165
+ export type CtFacetField = (typeof CT_FACET_FIELDS)[number];
166
+ const CT_FACET_FIELDS_SET: ReadonlySet<string> = new Set(CT_FACET_FIELDS);
167
+
168
+ // The ARRAY-valued members whose per-value counts OVERLAP (a study can hold several,
169
+ // e.g. PHASE1|PHASE2) so Σ counts + missing OVERSHOOTS the registry total (Phase:
170
+ // live-verified sum 477032 + missing 140698 = 617730 > 593334). The v1 whitelist's
171
+ // ONLY array member is Phase; all 10 others are scalar (sum + missing == 593334, an
172
+ // exact partition). A static Set (no /stats/size call needed) drives the not-a-
173
+ // partition note + the per-facet `overlapping` flag.
174
+ export const CT_FACET_ARRAY_FIELDS: ReadonlySet<string> = new Set(["Phase"]);
175
+
176
+ // ─── Client-side value grammars (SSRF + injection guards) ─────────
177
+ // [M2] NCT id = exactly 8 digits (P1/P11/P12 — every observed id). Validated
178
+ // BEFORE the single-study path is built (no `/`, `?`, `..`, `%2F`, space can be
179
+ // injected — belt-and-suspenders behind the server's Zod, load-bearing for a
180
+ // direct handler call that bypasses Zod).
181
+ export const CT_NCT_RE = /^NCT\d{8}$/;
182
+ // The opaque pageToken alphabet is a base64/URL-safe SUPERSET (the real injection
183
+ // guard is URLSearchParams encoding; the regex bounds length + rejects obvious
184
+ // garbage). Mirrors GovInfo's page-mark regex MINUS the `"*"` sentinel (CT has no
185
+ // first-page sentinel — the first page is simply a call with no pageToken) AND
186
+ // MINUS a literal `%` — URLSearchParams would double-encode a `%` (`%2e`→`%252e`)
187
+ // and corrupt the cursor; CT's observed tokens are base64url (no `%`), so a stray
188
+ // `%` is rejected as invalid_input rather than silently corrupting pagination.
189
+ export const CT_TOKEN_RE = new RegExp("^[A-Za-z0-9+/=_~.,:-]{1,4096}$");
190
+
191
+ // ─── Disclosure constants (honesty obligations) ──────────────────
192
+ /** The mandatory trial≠federal-award caveat carried in EVERY response. */
193
+ const CT_TRIAL_CAVEAT =
194
+ "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.";
195
+
196
+ /** The opaque-cursor disclosure (offset/nextOffset are meaningless). */
197
+ const CT_CURSOR_NOTE =
198
+ "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.";
199
+
200
+ /** funderType is a NON-EXCLUSIVE facet (values overlap; sum > registry total). */
201
+ const CT_FUNDER_OVERLAP_NOTE =
202
+ "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.";
203
+
204
+ /** query.spons is a fuzzy sponsor NAME search, not an exact-entity join. Emitted
205
+ * ONLY for a SINGLE-token sponsor: for a MULTI-token sponsor CT AND-splits and
206
+ * NARROWS (e.g. 'Sanofi-Aventis' → 'Sanofi' AND 'Aventis'), so the "matches more
207
+ * variants" broadening framing here would be the OPPOSITE of what happened — the
208
+ * AND-note (andTokenNote) takes precedence in that case (see searchStudies). */
209
+ const CT_SPONSOR_NOTE =
210
+ "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).";
211
+
212
+ /** A conservative data-currency note (not API-verifiable). */
213
+ const CT_DATA_CURRENCY_NOTE =
214
+ "ClinicalTrials.gov updates registrations on a rolling basis; per-record refresh lag is not API-verifiable.";
215
+
216
+ // ─── Facet-counts disclosure constants (ADR-0024 honesty obligations) ──────
217
+ const CT_FACET_SOURCE =
218
+ "clinicaltrials.gov /api/v2/stats/field/values (keyless)";
219
+
220
+ /**
221
+ * [M1] totalAvailable/returned UNIT disclosure. For the facet tool ONLY,
222
+ * `_meta.totalAvailable = Σ facet.uniqueValuesCount` and `_meta.returned =
223
+ * Σ facet.values.length` — these drive buildMeta's `returned < totalAvailable ⇒
224
+ * truncated` invariant, but they count DISTINCT FIELD VALUES, NOT studies (in
225
+ * every OTHER tool, incl. clinicaltrials_search_studies, totalAvailable is a
226
+ * study/record match count). Mandatory on every facet response so an AI never
227
+ * reads the distinct-value total as a study total.
228
+ */
229
+ const CT_FACET_UNIT_NOTE =
230
+ "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).";
231
+
232
+ /**
233
+ * [M2] Whole-registry scope note — NO hard-coded registry size (the registry only
234
+ * grows; freezing a total in a truthfulness string would go stale). These counts
235
+ * are ALWAYS over the ENTIRE registry and are UNfilterable (query/filter/countTotal/
236
+ * pageSize params all HTTP-400 here). Cross-links the sibling row tool for filtered
237
+ * totals.
238
+ */
239
+ const CT_FACET_SCOPE_NOTE =
240
+ "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).";
241
+
242
+ /**
243
+ * The FACET-SCOPED trial≠federal-award caveat (EVERY facet response). Carries the
244
+ * same trial-registration ≠ federal-award substance as the row-level
245
+ * CT_TRIAL_CAVEAT, but REWORDED for a distribution output — it describes the
246
+ * LeadSponsorClass DISTRIBUTION, not a row-level leadSponsor.name free-text field.
247
+ */
248
+ const CT_FACET_TRIAL_CAVEAT =
249
+ "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.";
250
+
251
+ /** Per-facet TOP-N truncation note — the 250-cap disclosure. Never fires for the
252
+ * v1 ENUM whitelist (all ≤14 unique ≪ 250) but is load-bearing: if the whitelist
253
+ * is ever extended to a high-cardinality (STRING) field, this discloses the cap. */
254
+ function ctFacetTruncationNote(field: string, unique: number, returned: number): string {
255
+ 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.`;
256
+ }
257
+
258
+ /** Per-facet not-a-partition note — an ARRAY-valued field (Phase) whose per-value
259
+ * counts OVERLAP and MUST NOT be summed to a registry total. */
260
+ function ctFacetOverlapNote(field: string): string {
261
+ 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.`;
262
+ }
263
+
264
+ /** Per-facet high-missing interpretation note — when more studies LACK a value for
265
+ * the field than are represented across all shown buckets, the distribution covers
266
+ * a MINORITY of the registry. `missing` is this response's exact (live) count — NOT
267
+ * a frozen constant (M2). */
268
+ function ctFacetHighMissingNote(field: string, missing: number): string {
269
+ 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).`;
270
+ }
271
+
272
+ // CT's Essie analyzer AND-tokenizes free-text query.spons/query.cond/query.term on
273
+ // whitespace AND the confirmed PUNCTUATION set (space + `- , / ; + & | @ # =` split
274
+ // into the AND co-occurrence; `. : _ '` do NOT — live-verified 2026-07-12), so a
275
+ // single-token-LOOKING compound like "Sanofi-Aventis" is really 'Sanofi' AND
276
+ // 'Aventis' (→3 vs Sanofi→3416 — a ~1000× silent false-negative). That precise
277
+ // class is the SHARED tokenizeForDisclosure / DISCLOSURE_SPLIT_RE (src/disclosure.js,
278
+ // ADR-0022) — byte-identical to NSF's former CT_TOKEN_SPLIT_RE / NSF_KEYWORD_SPLIT_RE
279
+ // (there OR, here AND) — so a whitespace-only detector can never miss the AND-note.
280
+
281
+ /** The AND-tokenization disclosure for a multi-TOKEN term/sponsor/condition value. */
282
+ function andTokenNote(field: string, tokens: string[]): string {
283
+ 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.`;
284
+ }
285
+
286
+ const CT_SOURCE = "clinicaltrials.gov /api/v2/studies (keyless)";
287
+
288
+ // ─── Curated record shape (the ENTITY/sponsor/funding dimension) ───
289
+ export type CtEntity = {
290
+ name: string | null;
291
+ class: string | null;
292
+ };
293
+
294
+ export type ClinicalStudy = {
295
+ nctId: string | null;
296
+ briefTitle: string | null;
297
+ orgStudyId: string | null;
298
+ /** identificationModule.organization — the REGISTERING org. */
299
+ organization: CtEntity;
300
+ /** sponsorCollaboratorsModule.leadSponsor — class = the FUNDING SOURCE. */
301
+ leadSponsor: CtEntity;
302
+ /** sponsorCollaboratorsModule.collaborators[] — [] when none listed (honest). */
303
+ collaborators: CtEntity[];
304
+ /** leadSponsor.class promoted (NIH/FED = federally-funded; the B2G axis). */
305
+ fundingClass: string | null;
306
+ overallStatus: string | null;
307
+ startDate: string | null;
308
+ studyType: string | null;
309
+ phases: string[];
310
+ conditions: string[];
311
+ /** ONLY populated by clinicaltrials_get_study (search rows OMIT it — payload). */
312
+ briefSummary?: string | null;
313
+ };
314
+
315
+ /** A string array from a mixed value, else [] (drops non-string entries). */
316
+ function strArray(x: unknown): string[] {
317
+ if (!Array.isArray(x)) return [];
318
+ return x.map((v) => str(v)).filter((v): v is string => v !== null);
319
+ }
320
+
321
+ /** Map a collaborators[]-style array of {name,class} entities (honest [] when absent). */
322
+ function mapEntities(x: unknown): CtEntity[] {
323
+ if (!Array.isArray(x)) return [];
324
+ return x.map((c) => {
325
+ const it = (c ?? {}) as Record<string, unknown>;
326
+ return { name: str(it.name), class: str(it.class) };
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Map ONE study (a search `studies[]` row OR the get single-study body — both
332
+ * carry `protocolSection`) → the curated entity shape. Every scalar is
333
+ * null-never-fabricated (str, null-never-empty-string — a missing/blank sponsor
334
+ * name is null, NEVER ""). collaborators/phases/conditions default to [] (an
335
+ * honest "none listed"). `briefSummary` is included ONLY for get_study.
336
+ */
337
+ function mapStudy(item: unknown, includeSummary: boolean): ClinicalStudy {
338
+ const it = (item ?? {}) as Record<string, unknown>;
339
+ const ps = (it.protocolSection ?? {}) as Record<string, unknown>;
340
+ const idm = (ps.identificationModule ?? {}) as Record<string, unknown>;
341
+ const org = (idm.organization ?? {}) as Record<string, unknown>;
342
+ const orgStudyIdInfo = (idm.orgStudyIdInfo ?? {}) as Record<string, unknown>;
343
+ const scm = (ps.sponsorCollaboratorsModule ?? {}) as Record<string, unknown>;
344
+ const lead = (scm.leadSponsor ?? {}) as Record<string, unknown>;
345
+ const statusM = (ps.statusModule ?? {}) as Record<string, unknown>;
346
+ const startStruct = (statusM.startDateStruct ?? {}) as Record<string, unknown>;
347
+ const designM = (ps.designModule ?? {}) as Record<string, unknown>;
348
+ const condM = (ps.conditionsModule ?? {}) as Record<string, unknown>;
349
+ const descM = (ps.descriptionModule ?? {}) as Record<string, unknown>;
350
+
351
+ const leadClass = str(lead.class);
352
+ const study: ClinicalStudy = {
353
+ nctId: str(idm.nctId),
354
+ briefTitle: str(idm.briefTitle),
355
+ orgStudyId: str(orgStudyIdInfo.id),
356
+ organization: { name: str(org.fullName), class: str(org.class) },
357
+ leadSponsor: { name: str(lead.name), class: leadClass },
358
+ collaborators: mapEntities(scm.collaborators),
359
+ fundingClass: leadClass,
360
+ overallStatus: str(statusM.overallStatus),
361
+ startDate: str(startStruct.date),
362
+ studyType: str(designM.studyType),
363
+ phases: strArray(designM.phases),
364
+ conditions: strArray(condM.conditions),
365
+ };
366
+ if (includeSummary) study.briefSummary = str(descM.briefSummary);
367
+ return study;
368
+ }
369
+
370
+ // ─── SSRF-guarded fetch ([M2] — one audited fetch home for BOTH tools) ──
371
+ /**
372
+ * GET one ClinicalTrials.gov v2 JSON resource. `path` is `/studies` (search) or
373
+ * `/studies/{nctId}` (get — the caller validates `nctId` `^NCT\d{8}$` BEFORE
374
+ * building the path); all query params ride in `params` (URLSearchParams,
375
+ * encoded). Builds `${CT_BASE}${path}?${params}` on the FIXED host literal, then
376
+ * asserts the CONSTRUCTED URL is `clinicaltrials.gov` over https (belt-and-
377
+ * suspenders — a future constant typo/downgrade fails closed), sets
378
+ * `redirect:"error"` (an off-host 3xx fails closed — its body is never read). NO
379
+ * headers (keyless — no key/UA required, byte-clean init). Returns the parsed
380
+ * JSON (unknown; the caller validates the response envelope + throws driftError).
381
+ */
382
+ async function getCT(
383
+ path: string,
384
+ label: string,
385
+ params: URLSearchParams,
386
+ ): Promise<unknown> {
387
+ const qs = params.toString();
388
+ const url = `${CT_BASE}${path}${qs ? `?${qs}` : ""}`;
389
+ const built = new URL(url);
390
+ if (built.protocol !== "https:" || built.hostname !== CT_HOST) {
391
+ throw new ToolErrorCarrier({
392
+ kind: "invalid_input",
393
+ 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).`,
394
+ retryable: false,
395
+ upstreamEndpoint: label,
396
+ });
397
+ }
398
+ return getJson(url, { label, redirect: "error" });
399
+ }
400
+
401
+ // ─── Tool 1: clinicaltrials_search_studies ────────────────────────
402
+ export type CtSearchArgs = {
403
+ "query.term"?: string;
404
+ sponsor?: string;
405
+ condition?: string;
406
+ location?: string;
407
+ overallStatus?: CtStatus;
408
+ funderType?: CtFunderType;
409
+ pageSize?: number;
410
+ pageToken?: string;
411
+ };
412
+
413
+ /**
414
+ * Search federally-registered clinical studies with sponsor / condition /
415
+ * location / status / funding-source filters. Each shipped filter is
416
+ * LIVE-CONFIRMED to narrow; the query is MODULE-BUILT from validated typed args
417
+ * through URLSearchParams (NO raw passthrough) with `countTotal=true` ALWAYS
418
+ * appended (§Honesty #1). Returns curated ENTITY rows (briefSummary EXCLUDED —
419
+ * payload) + honest `_meta`: the EXACT filtered total, the opaque-cursor
420
+ * continuation, the AND-tokenization disclosure, the funderType-overlap note, and
421
+ * the mandatory trial≠federal-award caveat. Disclose-not-refuse: an unscoped call
422
+ * is NOT refused — it returns the first page + the exact total + a narrowing note.
423
+ */
424
+ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
425
+ const pageSize = args.pageSize ?? 20;
426
+
427
+ // ── [M1] In-handler enum re-guard (funderType AND overallStatus) — throw
428
+ // invalid_input PRE-fetch (0 network call), mirroring govinfo's in-handler
429
+ // collection re-check. This is load-bearing for a DIRECT handler call that
430
+ // bypasses Zod: an unknown funderType would build aggFilters=funderType:<v>
431
+ // and get a fabricated HTTP-200 totalCount:0 (the silent fake-empty trap). ──
432
+ if (
433
+ args.funderType !== undefined &&
434
+ !CT_FUNDER_TYPES_SET.has(args.funderType)
435
+ ) {
436
+ throw new ToolErrorCarrier({
437
+ kind: "invalid_input",
438
+ 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.`,
439
+ retryable: false,
440
+ upstreamEndpoint: CT_LABEL,
441
+ });
442
+ }
443
+ if (
444
+ args.overallStatus !== undefined &&
445
+ !CT_STATUSES_SET.has(args.overallStatus)
446
+ ) {
447
+ throw new ToolErrorCarrier({
448
+ kind: "invalid_input",
449
+ message: `overallStatus ${JSON.stringify(args.overallStatus)} is not one of the ${CT_STATUSES.length} ClinicalTrials.gov statuses (${CT_STATUSES.join(", ")}) — refused before any fetch.`,
450
+ retryable: false,
451
+ upstreamEndpoint: CT_LABEL,
452
+ });
453
+ }
454
+ // ── Belt-and-suspenders pageToken + pageSize grammars (behind the server's
455
+ // Zod). A bad token would loud-fail at HTTP 400, but reject it pre-fetch. ──
456
+ if (args.pageToken !== undefined && !CT_TOKEN_RE.test(args.pageToken)) {
457
+ throw new ToolErrorCarrier({
458
+ kind: "invalid_input",
459
+ message: `Invalid pageToken (opaque cursor) — must be a ≤4096-char base64/URL-safe token. Pass back the _meta.nextCursor from the previous page.`,
460
+ retryable: false,
461
+ upstreamEndpoint: CT_LABEL,
462
+ });
463
+ }
464
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) {
465
+ throw new ToolErrorCarrier({
466
+ kind: "invalid_input",
467
+ message: `Invalid pageSize ${JSON.stringify(pageSize)} — must be an integer in [1, 1000] (ClinicalTrials.gov clamps a larger request to 1000).`,
468
+ retryable: false,
469
+ upstreamEndpoint: CT_LABEL,
470
+ });
471
+ }
472
+
473
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
474
+ // passthrough). countTotal=true is ALWAYS appended (§Honesty #1 — omitting
475
+ // it drops the total entirely). ──
476
+ const params = new URLSearchParams();
477
+ const filtersApplied: string[] = [];
478
+ const andNotes: string[] = [];
479
+
480
+ // A helper: push a filter + detect MULTI-TOKEN AND-tokenization on CT's REAL
481
+ // delimiter set via the shared tokenizeForDisclosure (whitespace AND the confirmed
482
+ // punctuation splitters — DISCLOSURE_SPLIT_RE), so a compound like "Sanofi-Aventis"
483
+ // (= Sanofi AND Aventis) fires the mandatory AND-note instead of leaking as one
484
+ // token through a hyphen/comma/slash. Returns true iff the value split into 2+
485
+ // tokens (so the caller can suppress the contradictory sponsor-broadening note).
486
+ // andField=null ONLY for a field with no AND-note obligation. term/sponsor/
487
+ // condition AND location all ride the SAME Essie AND-tokenizer (query.locn is
488
+ // AND-split exactly like query.term/spons/cond — a multi-word location like
489
+ // "New York" is 'New' AND 'York'), so each passes its own andField label.
490
+ const pushText = (
491
+ value: string | undefined,
492
+ upstreamKey: string,
493
+ filterLabel: string,
494
+ andField: string | null,
495
+ ): boolean => {
496
+ if (value === undefined) return false;
497
+ params.set(upstreamKey, value);
498
+ filtersApplied.push(filterLabel);
499
+ if (andField === null) return false;
500
+ const tokens = tokenizeForDisclosure(value);
501
+ if (tokens.length > 1) {
502
+ andNotes.push(andTokenNote(andField, tokens));
503
+ return true;
504
+ }
505
+ return false;
506
+ };
507
+
508
+ pushText(args["query.term"], "query.term", "query.term", "term");
509
+ const sponsorMultiToken = pushText(args.sponsor, "query.spons", "sponsor", "sponsor");
510
+ pushText(args.condition, "query.cond", "condition", "condition");
511
+ // D2 (no-silent-filter): location (→ query.locn) is AND-tokenized identically to
512
+ // term/sponsor/condition — so a multi-word location must emit the SAME mandatory
513
+ // AND-note (was silently exempted with andField=null, hiding the narrowing).
514
+ pushText(args.location, "query.locn", "location", "location");
515
+
516
+ if (args.overallStatus !== undefined) {
517
+ params.set("filter.overallStatus", args.overallStatus);
518
+ filtersApplied.push("overallStatus");
519
+ }
520
+ if (args.funderType !== undefined) {
521
+ // The module builds the aggFilters string from the enum-validated value —
522
+ // never from raw caller text (§SSRF #3).
523
+ params.set("aggFilters", `funderType:${args.funderType}`);
524
+ filtersApplied.push("funderType");
525
+ }
526
+
527
+ params.set("countTotal", "true"); // ALWAYS — §Honesty #1
528
+ params.set("pageSize", String(pageSize));
529
+ if (args.pageToken !== undefined) params.set("pageToken", args.pageToken);
530
+
531
+ const body = await getCT(CT_STUDIES_PATH, CT_LABEL, params);
532
+ const b = (body ?? {}) as {
533
+ studies?: unknown;
534
+ totalCount?: unknown;
535
+ nextPageToken?: unknown;
536
+ };
537
+
538
+ // ── Container-guarded drift (never a TypeError masking drift as
539
+ // upstream_unavailable, never a fake empty). ──
540
+ if (!Array.isArray(b.studies)) {
541
+ throw driftError(
542
+ CT_LABEL,
543
+ "clinicaltrials shape drift — /studies response.studies must be an array.",
544
+ );
545
+ }
546
+ // countTotal=true was sent, so totalCount MUST be a finite number — its absence
547
+ // is drift, NEVER a silently-null total, NEVER studies.length (§Honesty #1).
548
+ if (typeof b.totalCount !== "number" || !Number.isFinite(b.totalCount)) {
549
+ throw driftError(
550
+ CT_LABEL,
551
+ "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).",
552
+ );
553
+ }
554
+
555
+ const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
556
+ const returned = studies.length;
557
+ const totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
558
+
559
+ // ── Opaque-cursor honesty (§Honesty #2). Terminal = token ABSENT. The token is
560
+ // surfaced VERBATIM (never fabricated/derived). Phantom-empty guard: 0
561
+ // studies WITH a token ⇒ terminal (never advertise a continuation into an
562
+ // empty cursor loop). ──
563
+ const rawNext = b.nextPageToken;
564
+ const nextToken =
565
+ typeof rawNext === "string" && rawNext.length > 0 ? rawNext : null;
566
+ let hasMore: boolean;
567
+ let nextCursor: string | null;
568
+ if (returned === 0 && nextToken !== null) {
569
+ hasMore = false;
570
+ nextCursor = null;
571
+ } else {
572
+ hasMore = nextToken !== null;
573
+ nextCursor = nextToken;
574
+ }
575
+
576
+ // ── Notes: the mandatory caveat + cursor + data-currency always; the
577
+ // conditional facet/tokenization disclosures; the unscoped recommendation. ──
578
+ const notes: string[] = [CT_TRIAL_CAVEAT, CT_CURSOR_NOTE];
579
+ notes.push(...andNotes);
580
+ // Emit the sponsor-broadening note ONLY for a SINGLE-token sponsor. For a
581
+ // MULTI-token sponsor CT AND-split and NARROWED (the andNotes AND-note fired),
582
+ // so the "matches more variants" framing would AFFIRMATIVELY MISLEAD (the
583
+ // opposite of what happened) — the AND-note is what the caller must see.
584
+ if (args.sponsor !== undefined && !sponsorMultiToken)
585
+ notes.push(CT_SPONSOR_NOTE);
586
+ if (args.funderType !== undefined) notes.push(CT_FUNDER_OVERLAP_NOTE);
587
+ if (filtersApplied.length === 0) {
588
+ notes.push(
589
+ "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.",
590
+ );
591
+ }
592
+ notes.push(CT_DATA_CURRENCY_NOTE);
593
+
594
+ return withMeta(
595
+ { studies },
596
+ {
597
+ source: CT_SOURCE,
598
+ keylessMode: true,
599
+ returned,
600
+ totalAvailable,
601
+ // complete is DERIVED by buildMeta (never forced): a first-page call whose
602
+ // returned === totalAvailable with no token ⇒ complete:true; a continuation
603
+ // page (or any page with returned < total / a token) ⇒ complete:false.
604
+ filtersApplied,
605
+ filtersDropped: [],
606
+ fieldsUnavailable: [
607
+ "briefSummary (search rows omit it — use clinicaltrials_get_study)",
608
+ ],
609
+ // Cursor page: offset/nextOffset null (no numeric offset); continuation is
610
+ // nextCursor (the opaque nextPageToken, passed back verbatim as pageToken).
611
+ pagination: { offset: null, limit: pageSize, hasMore, nextOffset: null },
612
+ nextCursor,
613
+ notes,
614
+ } satisfies Partial<ResponseMeta>,
615
+ );
616
+ }
617
+
618
+ // ─── Tool 2: clinicaltrials_get_study ─────────────────────────────
619
+ /**
620
+ * Fetch ONE study by its NCT id; returns the fuller single record (entity fields
621
+ * + briefSummary). [M2] `nctId` is regex-validated `^NCT\d{8}$` BEFORE the path is
622
+ * built (a `../` / non-matching id ⇒ invalid_input, 0 fetch). A 404 (nonexistent
623
+ * NCT id) ⇒ honest found:false (never a fabricated study).
624
+ */
625
+ export async function getStudy(args: { nctId: string }): Promise<MetaBundle> {
626
+ const nctId = args.nctId;
627
+ // [M2] Validate BEFORE building the path (belt-and-suspenders behind the
628
+ // server's Zod — load-bearing for a direct call; a traversal never reaches the
629
+ // path). 0 fetch on a mismatch.
630
+ if (!CT_NCT_RE.test(nctId)) {
631
+ throw new ToolErrorCarrier({
632
+ kind: "invalid_input",
633
+ 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).`,
634
+ retryable: false,
635
+ upstreamEndpoint: CT_LABEL,
636
+ });
637
+ }
638
+
639
+ const notes: string[] = [CT_TRIAL_CAVEAT, CT_DATA_CURRENCY_NOTE];
640
+
641
+ let body: unknown;
642
+ try {
643
+ body = await getCT(
644
+ `${CT_STUDIES_PATH}/${nctId}`,
645
+ CT_LABEL,
646
+ new URLSearchParams(),
647
+ );
648
+ } catch (e) {
649
+ // A nonexistent NCT id ⇒ HTTP 404 ⇒ getJson throws not_found ⇒ honest
650
+ // found:false (a definitive answer, never a fabricated study).
651
+ if (e instanceof ToolErrorCarrier && e.toolError.kind === "not_found") {
652
+ return withMeta(
653
+ { found: false, nctId, study: null as ClinicalStudy | null },
654
+ {
655
+ source: CT_SOURCE,
656
+ keylessMode: true,
657
+ returned: 0,
658
+ totalAvailable: 0,
659
+ complete: true,
660
+ filtersApplied: ["nctId"],
661
+ filtersDropped: [],
662
+ fieldsUnavailable: [],
663
+ notes: [
664
+ `No ClinicalTrials.gov study found for nctId ${JSON.stringify(nctId)} (HTTP 404) — the id does not exist. Not fabricated.`,
665
+ ...notes,
666
+ ],
667
+ } satisfies Partial<ResponseMeta>,
668
+ );
669
+ }
670
+ throw e;
671
+ }
672
+
673
+ // A valid single-study 200 carries protocolSection at the TOP level (P11). Its
674
+ // absence is drift (never a fabricated all-null record).
675
+ const bo = (body ?? {}) as { protocolSection?: unknown };
676
+ if (
677
+ bo.protocolSection === null ||
678
+ typeof bo.protocolSection !== "object" ||
679
+ Array.isArray(bo.protocolSection)
680
+ ) {
681
+ throw driftError(
682
+ CT_LABEL,
683
+ "clinicaltrials shape drift — /studies/{nctId} response is missing a protocolSection object.",
684
+ );
685
+ }
686
+
687
+ const study = mapStudy(body, true); // FULL record incl. briefSummary
688
+ return withMeta(
689
+ { found: true, nctId, study },
690
+ {
691
+ source: CT_SOURCE,
692
+ keylessMode: true,
693
+ returned: 1,
694
+ totalAvailable: 1,
695
+ complete: true,
696
+ filtersApplied: ["nctId"],
697
+ filtersDropped: [],
698
+ fieldsUnavailable: [],
699
+ notes,
700
+ } satisfies Partial<ResponseMeta>,
701
+ );
702
+ }
703
+
704
+ // ─── Tool 3: clinicaltrials_facet_counts (ADR-0024) ───────────────
705
+ export type CtFacetValue = {
706
+ /** The enum value (str — null-never-empty-string). */
707
+ value: string | null;
708
+ /** The EXACT per-value study count (num — null-never-0; a non-number ⇒ drift). */
709
+ studiesCount: number | null;
710
+ };
711
+
712
+ export type CtFacet = {
713
+ /** The requested/echoed field name (the `piece`). */
714
+ field: string;
715
+ /** The dotted upstream JSON path (the `field`), e.g. protocolSection.statusModule.overallStatus. */
716
+ fieldPath: string | null;
717
+ /** The echoed upstream `type` (asserted "ENUM"; else drift). */
718
+ valueType: string | null;
719
+ /** Distinct values that exist upstream (num — non-number ⇒ drift). */
720
+ uniqueValuesCount: number | null;
721
+ /** Studies with NO value for this field (num — null-never-0). */
722
+ missingStudiesCount: number | null;
723
+ /** values.length (== uniqueValuesCount for a COMPLETE ENUM facet). */
724
+ returned: number;
725
+ /** returned < uniqueValuesCount (the 250-cap surface; never true for v1 ENUMs). */
726
+ truncated: boolean;
727
+ /** true for an ARRAY-valued field (Phase) — counts OVERLAP, must not be summed. */
728
+ overlapping: boolean;
729
+ /** The EXACT per-value distribution. */
730
+ values: CtFacetValue[];
731
+ };
732
+
733
+ /**
734
+ * Map ONE `/stats/field/values` facet element → the curated facet shape (a FRESH
735
+ * mapper — does NOT reuse mapStudy/strArray/mapEntities, which are row-shaped).
736
+ * ENUM-ONLY drift guard (§Honesty #7): the element MUST be `type:"ENUM"` with an
737
+ * ARRAY `topValues` and a NUMBER `uniqueValuesCount` — a BOOLEAN
738
+ * (`{trueCount,falseCount}`, no topValues) / STRING / re-typed shape for a
739
+ * whitelisted field is schema_drift, NEVER read as an empty distribution. Each
740
+ * `studiesCount` is typeof-checked to a finite NUMBER BEFORE `num()` (mirrors the
741
+ * search tool's totalCount guard) so a non-number can NEVER silently parse or
742
+ * coerce-to-0 — an EXACT count or drift, never a fabricated 0.
743
+ */
744
+ function mapFacet(requested: string, item: unknown): CtFacet {
745
+ const o = (item ?? {}) as Record<string, unknown>;
746
+ if (o.type !== "ENUM") {
747
+ throw driftError(
748
+ CT_STATS_FIELDS_LABEL,
749
+ `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.`,
750
+ );
751
+ }
752
+ if (!Array.isArray(o.topValues)) {
753
+ throw driftError(
754
+ CT_STATS_FIELDS_LABEL,
755
+ `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).`,
756
+ );
757
+ }
758
+ if (typeof o.uniqueValuesCount !== "number" || !Number.isFinite(o.uniqueValuesCount)) {
759
+ throw driftError(
760
+ CT_STATS_FIELDS_LABEL,
761
+ `clinicaltrials facet shape drift — field '${requested}' uniqueValuesCount is missing/non-number (typeof-checked BEFORE num() so a string can't silently parse).`,
762
+ );
763
+ }
764
+ const values: CtFacetValue[] = (o.topValues as unknown[]).map((v) => {
765
+ const it = (v ?? {}) as Record<string, unknown>;
766
+ if (typeof it.studiesCount !== "number" || !Number.isFinite(it.studiesCount)) {
767
+ throw driftError(
768
+ CT_STATS_FIELDS_LABEL,
769
+ `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).`,
770
+ );
771
+ }
772
+ return { value: str(it.value), studiesCount: num(it.studiesCount) };
773
+ });
774
+ const uniqueValuesCount = num(o.uniqueValuesCount) as number;
775
+ const returned = values.length;
776
+ return {
777
+ field: requested,
778
+ fieldPath: str(o.field),
779
+ valueType: str(o.type),
780
+ uniqueValuesCount,
781
+ missingStudiesCount: num(o.missingStudiesCount),
782
+ returned,
783
+ // The universal truncation invariant: returned < unique ⇒ truncated (rolled up
784
+ // into buildMeta via the response-level returned/totalAvailable roll-up too).
785
+ truncated: returned < uniqueValuesCount,
786
+ overlapping: CT_FACET_ARRAY_FIELDS.has(requested),
787
+ values,
788
+ };
789
+ }
790
+
791
+ export type CtFacetArgs = { fields: string[] };
792
+
793
+ /**
794
+ * Aggregate / statistical view: EXACT per-value study counts over the WHOLE
795
+ * ClinicalTrials.gov registry for one or more whitelisted ENUM fields
796
+ * (studies-by-OverallStatus / by-Phase / by-LeadSponsorClass = the funding-source
797
+ * distribution / …). The aggregate SIBLING of clinicaltrials_search_studies (which
798
+ * gives the exact FILTERED total for a query) — this gives the exact WHOLE-REGISTRY
799
+ * distribution across a field's values. Reuses the shipped getCT verbatim (one new
800
+ * path constant), coerce.num/str, buildMeta/withMeta — ZERO new fetch/coerce/error/
801
+ * meta code.
802
+ */
803
+ export async function facetCounts(args: CtFacetArgs): Promise<MetaBundle> {
804
+ // ── [ssrf] In-handler field RE-GUARD (load-bearing for a Zod-BYPASSING direct
805
+ // call): re-check EACH requested field against the frozen CT_FACET_FIELDS_SET
806
+ // INLINE (mirror CT_FUNDER_TYPES_SET / CT_STATUSES_SET) BEFORE building params,
807
+ // and dedupe (preserving order). A non-member ⇒ invalid_input PRE-fetch, 0
808
+ // network call — NO raw field-name ever reaches the URL. ──
809
+ const requested = args.fields ?? [];
810
+ const seen = new Set<string>();
811
+ const fields: string[] = [];
812
+ for (const f of requested) {
813
+ if (!CT_FACET_FIELDS_SET.has(f)) {
814
+ throw new ToolErrorCarrier({
815
+ kind: "invalid_input",
816
+ 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).`,
817
+ retryable: false,
818
+ upstreamEndpoint: CT_STATS_FIELDS_LABEL,
819
+ });
820
+ }
821
+ if (!seen.has(f)) {
822
+ seen.add(f);
823
+ fields.push(f);
824
+ }
825
+ }
826
+ if (fields.length === 0) {
827
+ throw new ToolErrorCarrier({
828
+ kind: "invalid_input",
829
+ message: `at least one facet field is required (ships: ${CT_FACET_FIELDS.join(", ")}).`,
830
+ retryable: false,
831
+ upstreamEndpoint: CT_STATS_FIELDS_LABEL,
832
+ });
833
+ }
834
+
835
+ // ── Build the query from the enum-validated array (SSRF: module-built, comma-
836
+ // joined; each element is a frozen enum member — no raw passthrough). The ONLY
837
+ // query key is `fields`; no filter/scope/page key is EVER sent (they 400). ──
838
+ const params = new URLSearchParams();
839
+ params.set("fields", fields.join(","));
840
+
841
+ const body = await getCT(CT_STATS_FIELDS_PATH, CT_STATS_FIELDS_LABEL, params);
842
+ // The 200 body is a TOP-LEVEL ARRAY (one object per requested field). Its absence
843
+ // is drift, NEVER a fake-empty distribution. A 404 (a whitelisted field missing
844
+ // upstream = whitelist drift) / 400 / 5xx is THROWN by getJson (never caught).
845
+ if (!Array.isArray(body)) {
846
+ throw driftError(
847
+ CT_STATS_FIELDS_LABEL,
848
+ "clinicaltrials facet shape drift — /stats/field/values response must be a top-level array (one object per requested field).",
849
+ );
850
+ }
851
+
852
+ // Match each requested field to its response element by the echoed `piece`
853
+ // (robust to element ordering); a whitelisted field absent from the response is
854
+ // whitelist drift ⇒ driftError, never a silent empty.
855
+ const byPiece = new Map<string, unknown>();
856
+ for (const el of body as unknown[]) {
857
+ const p = (el ?? {}) as Record<string, unknown>;
858
+ if (typeof p.piece === "string" && !byPiece.has(p.piece)) byPiece.set(p.piece, el);
859
+ }
860
+ const facets: CtFacet[] = [];
861
+ for (const f of fields) {
862
+ const el = byPiece.get(f);
863
+ if (el === undefined) {
864
+ throw driftError(
865
+ CT_STATS_FIELDS_LABEL,
866
+ `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).`,
867
+ );
868
+ }
869
+ facets.push(mapFacet(f, el));
870
+ }
871
+
872
+ // ── [M1] Response-level roll-up: returned = Σ values.length, totalAvailable =
873
+ // Σ uniqueValuesCount (DISTINCT VALUES, not studies — the unit note discloses
874
+ // it). buildMeta's `returned < totalAvailable ⇒ truncated:true / complete:false`
875
+ // auto-derives truncation the instant ANY facet is capped; for the v1 ENUM
876
+ // whitelist returned == totalAvailable ⇒ complete:true, truncated:false. ──
877
+ const returned = facets.reduce((a, ff) => a + ff.values.length, 0);
878
+ const totalAvailable = facets.reduce(
879
+ (a, ff) => a + (ff.uniqueValuesCount ?? 0),
880
+ 0,
881
+ );
882
+
883
+ // ── Notes: the mandatory unit (M1) + scope (M2) always; per-facet truncation /
884
+ // overlap / high-missing conditionals; the facet-scoped trial≠award caveat +
885
+ // data-currency always. ──
886
+ const notes: string[] = [CT_FACET_UNIT_NOTE, CT_FACET_SCOPE_NOTE];
887
+ for (const ff of facets) {
888
+ if (ff.truncated && ff.uniqueValuesCount !== null) {
889
+ notes.push(ctFacetTruncationNote(ff.field, ff.uniqueValuesCount, ff.returned));
890
+ }
891
+ }
892
+ for (const ff of facets) {
893
+ if (ff.overlapping) notes.push(ctFacetOverlapNote(ff.field));
894
+ }
895
+ for (const ff of facets) {
896
+ const missing = ff.missingStudiesCount;
897
+ const shown = ff.values.reduce((a, v) => a + (v.studiesCount ?? 0), 0);
898
+ // Denominator-free (no frozen registry total, M2): the shown buckets cover a
899
+ // MINORITY when more studies lack a value than are represented across them.
900
+ if (missing !== null && missing > shown) {
901
+ notes.push(ctFacetHighMissingNote(ff.field, missing));
902
+ }
903
+ }
904
+ notes.push(CT_FACET_TRIAL_CAVEAT, CT_DATA_CURRENCY_NOTE);
905
+
906
+ return withMeta(
907
+ { facets },
908
+ {
909
+ source: CT_FACET_SOURCE,
910
+ keylessMode: true,
911
+ returned,
912
+ totalAvailable,
913
+ // complete/truncated DERIVED by buildMeta from returned/totalAvailable (never
914
+ // forced): v1 ENUM ⇒ complete:true; a future capped facet ⇒ complete:false.
915
+ filtersApplied: [],
916
+ filtersDropped: [],
917
+ fieldsUnavailable: [],
918
+ // NO pagination object — /stats/field/values is un-paged (pageSize 400s);
919
+ // leave `pagination` undefined (NOT {hasMore:false}).
920
+ notes,
921
+ } satisfies Partial<ResponseMeta>,
922
+ );
923
+ }