@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.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +226 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3889 -296
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- 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
|
+
}
|