@cliwant/mcp-sam-gov 1.2.0 → 1.4.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/README.ja.md +22 -9
- package/README.ko.md +22 -9
- package/README.md +70 -12
- package/dist/bea.d.ts +105 -0
- package/dist/bea.d.ts.map +1 -0
- package/dist/bea.js +303 -0
- package/dist/bea.js.map +1 -0
- package/dist/census-economic.d.ts +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +12 -6
- package/dist/census-economic.js.map +1 -1
- package/dist/cms-facility.d.ts +112 -0
- package/dist/cms-facility.d.ts.map +1 -0
- package/dist/cms-facility.js +311 -0
- package/dist/cms-facility.js.map +1 -0
- package/dist/cms-hospital.d.ts +105 -0
- package/dist/cms-hospital.d.ts.map +1 -0
- package/dist/cms-hospital.js +290 -0
- package/dist/cms-hospital.js.map +1 -0
- package/dist/cms-supplier.d.ts +133 -0
- package/dist/cms-supplier.d.ts.map +1 -0
- package/dist/cms-supplier.js +414 -0
- package/dist/cms-supplier.js.map +1 -0
- package/dist/cms-utilization.d.ts +113 -0
- package/dist/cms-utilization.d.ts.map +1 -0
- package/dist/cms-utilization.js +328 -0
- package/dist/cms-utilization.js.map +1 -0
- package/dist/courtlistener.d.ts +115 -0
- package/dist/courtlistener.d.ts.map +1 -0
- package/dist/courtlistener.js +398 -0
- package/dist/courtlistener.js.map +1 -0
- package/dist/cpsc.d.ts +81 -0
- package/dist/cpsc.d.ts.map +1 -0
- package/dist/cpsc.js +283 -0
- package/dist/cpsc.js.map +1 -0
- package/dist/dol.d.ts +118 -0
- package/dist/dol.d.ts.map +1 -0
- package/dist/dol.js +421 -0
- package/dist/dol.js.map +1 -0
- package/dist/epa-envirofacts.d.ts +97 -0
- package/dist/epa-envirofacts.d.ts.map +1 -0
- package/dist/epa-envirofacts.js +292 -0
- package/dist/epa-envirofacts.js.map +1 -0
- package/dist/fred.d.ts +1 -1
- package/dist/fred.js +1 -1
- package/dist/keys.d.ts +11 -8
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +55 -8
- package/dist/keys.js.map +1 -1
- package/dist/lda.d.ts +105 -0
- package/dist/lda.d.ts.map +1 -0
- package/dist/lda.js +317 -0
- package/dist/lda.js.map +1 -0
- package/dist/nhtsa.d.ts +91 -0
- package/dist/nhtsa.d.ts.map +1 -0
- package/dist/nhtsa.js +263 -0
- package/dist/nhtsa.js.map +1 -0
- package/dist/nonprofit.d.ts +116 -0
- package/dist/nonprofit.d.ts.map +1 -0
- package/dist/nonprofit.js +342 -0
- package/dist/nonprofit.js.map +1 -0
- package/dist/openfda-device.d.ts +85 -0
- package/dist/openfda-device.d.ts.map +1 -0
- package/dist/openfda-device.js +277 -0
- package/dist/openfda-device.js.map +1 -0
- package/dist/openfda.d.ts +133 -0
- package/dist/openfda.d.ts.map +1 -0
- package/dist/openfda.js +402 -0
- package/dist/openfda.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +872 -6
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/src/bea.ts +372 -0
- package/src/census-economic.ts +12 -6
- package/src/cms-facility.ts +379 -0
- package/src/cms-hospital.ts +344 -0
- package/src/cms-supplier.ts +527 -0
- package/src/cms-utilization.ts +389 -0
- package/src/courtlistener.ts +465 -0
- package/src/cpsc.ts +333 -0
- package/src/dol.ts +515 -0
- package/src/epa-envirofacts.ts +342 -0
- package/src/fred.ts +1 -1
- package/src/keys.ts +60 -8
- package/src/lda.ts +385 -0
- package/src/nhtsa.ts +352 -0
- package/src/nonprofit.ts +460 -0
- package/src/openfda-device.ts +356 -0
- package/src/openfda.ts +495 -0
- package/src/server.ts +995 -6
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cms-utilization.ts — CMS Medicare Physician & Other Practitioners "by Provider
|
|
3
|
+
* and Service" utilization (`data.cms.gov`, the data-API v1 dataset endpoint;
|
|
4
|
+
* ADR-0061). KEYLESS.
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT ADDS: `cms_medicare_provider_services` — a healthcare-market /
|
|
7
|
+
* competitor-utilization lane: for a given provider (NPI) or state, what Medicare
|
|
8
|
+
* Part-B services (HCPCS) did providers render, to how many beneficiaries, at what
|
|
9
|
+
* submitted / Medicare-allowed / Medicare-paid amounts. The demand-side complement
|
|
10
|
+
* to NPPES (who the providers ARE) — this is what they actually BILL.
|
|
11
|
+
*
|
|
12
|
+
* ★THE TWO-REQUEST PATTERN (the load-bearing P1 honesty — MIRRORS epa-envirofacts):
|
|
13
|
+
* the data-API's `/data` slice is a bare JSON array that reports NO total. So the
|
|
14
|
+
* EXACT total for a filter comes from a SEPARATE count sub-query — the identical
|
|
15
|
+
* `filter[...]` on the `/data-viewer/stats` endpoint returns
|
|
16
|
+
* `{ "data": { "found_rows": N, "total_rows": M } }`. This tool runs the stats
|
|
17
|
+
* count FIRST (best-effort) then the data slice: totalAvailable = found_rows (P1,
|
|
18
|
+
* the per-filter EXACT total), NEVER the returned rows' length. If the stats
|
|
19
|
+
* sub-query fails or is absent, totalAvailable falls to null + a disclosing note
|
|
20
|
+
* (never a length-faked total) and the data slice still returns.
|
|
21
|
+
*
|
|
22
|
+
* ★THE FILTER-REQUIRED INPUT GUARD: the table is 9.78M rows. An all-empty query
|
|
23
|
+
* (no npi, no state) is REFUSED with invalid_input (0 fetch) — providerType /
|
|
24
|
+
* hcpcsCode alone are NOT enough to scope; a caller MUST pin npi OR state.
|
|
25
|
+
*
|
|
26
|
+
* The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
|
|
27
|
+
* (redirect:"error") / `driftError` (datasource.ts), `str`/`num` (coerce.ts,
|
|
28
|
+
* null-never-empty-string / null-never-0), and `withMeta`·`buildMeta` (meta.ts,
|
|
29
|
+
* offset pagination + totalAvailable). It MIRRORS census-economic.ts's fixed-host
|
|
30
|
+
* SSRF idiom (a single host const + a post-construction hostname/protocol assertion
|
|
31
|
+
* + redirect:"error") and epa-envirofacts.ts's count-first two-request pattern +
|
|
32
|
+
* schema_drift catch-ladder (ToolErrorCarrier rethrow FIRST so a 5xx keeps its
|
|
33
|
+
* taxonomy → SyntaxError→driftError → bare rethrow).
|
|
34
|
+
*
|
|
35
|
+
* GET https://data.cms.gov/data-api/v1/dataset/{uuid}/data-viewer/stats
|
|
36
|
+
* ?filter[Rndrng_Prvdr_State_Abrvtn]=VA (the COUNT sub-query)
|
|
37
|
+
* → { "data": { "found_rows": 278254, "total_rows": 9781673 } }
|
|
38
|
+
* GET https://data.cms.gov/data-api/v1/dataset/{uuid}/data
|
|
39
|
+
* ?size=&offset=&filter[Rndrng_Prvdr_State_Abrvtn]=VA (the DATA slice)
|
|
40
|
+
* → [ { Rndrng_NPI, Rndrng_Prvdr_Last_Org_Name, …, Avg_Mdcr_Pymt_Amt }, … ]
|
|
41
|
+
*
|
|
42
|
+
* ★ SSRF: the host is a compile-time literal (`CMS_HOST`); the dataset UUID + the
|
|
43
|
+
* endpoint paths are MODULE literals. Every USER filter value rides as a
|
|
44
|
+
* URLSearchParams VALUE (`filter[Col]=Val`) — URLSearchParams encodes the bracket
|
|
45
|
+
* key AND the value, so a value can never break out into the path or inject a
|
|
46
|
+
* parameter. npi is `^\d{10}$`; state `^[A-Za-z]{2}$`; hcpcsCode `^[A-Za-z0-9]{1,10}$`;
|
|
47
|
+
* providerType is a bounded free-text charclass; size/offset are coerced to
|
|
48
|
+
* integers. A post-construction hostname/protocol assertion + `redirect:"error"`
|
|
49
|
+
* fail closed on any off-host 3xx.
|
|
50
|
+
*
|
|
51
|
+
* ★ PII NOTE: this is public PROVIDER-level AGGREGATE data — no patient identifiers.
|
|
52
|
+
* Provider name / practice address / NPI is public professional information (the
|
|
53
|
+
* same public surface as NPPES), so it is fine to surface.
|
|
54
|
+
*
|
|
55
|
+
* ★ HONESTY (ADR-0061 P1–P5, live-verified 2026-07-15 on data.cms.gov):
|
|
56
|
+
* [input] require npi OR state — an all-empty query is REFUSED (0 fetch) so the
|
|
57
|
+
* whole 9.78M-row table is never scanned.
|
|
58
|
+
* [P1] totalAvailable = the stats sub-query's found_rows (EXACT — e.g. VA =
|
|
59
|
+
* 278254), NOT the slice length. hasMore = offset+returned < total. Stats
|
|
60
|
+
* fails/absent ⇒ totalAvailable:null + a disclosing note.
|
|
61
|
+
* [P2] an empty array ⇒ honest empty (returned:0). getJson maps a 4xx/5xx via
|
|
62
|
+
* errorFromResponse and THROWS (503 ⇒ upstream_unavailable, 400 ⇒
|
|
63
|
+
* invalid_input, 404 ⇒ not_found); a 200 non-array/non-JSON body ⇒
|
|
64
|
+
* schema_drift (NEVER a fabricated empty).
|
|
65
|
+
* [P3] Tot_ / Avg_ fields via num() (numeric strings → numbers; a real 0 stays 0;
|
|
66
|
+
* absent ⇒ null, never 0-faked); NPI/codes/HCPCS/names as strings
|
|
67
|
+
* (null-never-empty-string).
|
|
68
|
+
* [P4] a data body that is not an array ⇒ driftError; a stats body missing
|
|
69
|
+
* found_rows ⇒ totalAvailable:null (handled, not a crash).
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
73
|
+
import { getJson, driftError } from "./datasource.js";
|
|
74
|
+
import { str, num } from "./coerce.js";
|
|
75
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
76
|
+
|
|
77
|
+
// Re-export the shared honesty coercions (single audited copy in ./coerce.js) so a
|
|
78
|
+
// regression fails together across sources. NO local num/str.
|
|
79
|
+
export { num, str };
|
|
80
|
+
|
|
81
|
+
// ─── SSRF core: the single fixed host + module-literal path pieces ──
|
|
82
|
+
const CMS_HOST = "data.cms.gov";
|
|
83
|
+
// HOST-only label — surfaces in ToolError.upstreamEndpoint; keyless, so no token
|
|
84
|
+
// can ever appear here.
|
|
85
|
+
const CMS_LABEL = "cms-utilization:data.cms.gov";
|
|
86
|
+
|
|
87
|
+
// ★THE DATASET UUID — a SPECIFIC ANNUAL VINTAGE of "Medicare Physician & Other
|
|
88
|
+
// Practitioners — by Provider and Service" (9,781,673 rows at build time, the most
|
|
89
|
+
// recent published year). ★UPDATE YEARLY: CMS publishes a NEW uuid for each new
|
|
90
|
+
// calendar year of utilization; this constant pins ONE vintage. The active vintage
|
|
91
|
+
// is surfaced to the caller in a _meta note (VINTAGE_NOTE) so a consumer never
|
|
92
|
+
// mistakes it for "current" or an unspecified year.
|
|
93
|
+
const CMS_DATASET_UUID = "92396110-2aed-4d63-a6a2-5d6207d46a29";
|
|
94
|
+
|
|
95
|
+
// ─── Validation charclasses (SSRF + "verify the input" honesty) ───
|
|
96
|
+
const NPI_RE = /^\d{10}$/; // a 10-digit National Provider Identifier
|
|
97
|
+
const STATE_RE = /^[A-Za-z]{2}$/; // 2-letter state/territory abbreviation
|
|
98
|
+
const HCPCS_RE = /^[A-Za-z0-9]{1,10}$/; // HCPCS/CPT code, e.g. "97110", "G0463"
|
|
99
|
+
// providerType is a specialty label, e.g. "Physical Therapist in Private Practice".
|
|
100
|
+
// It rides as a URLSearchParams VALUE (encoded), so this bound is a sanity guard,
|
|
101
|
+
// not an SSRF necessity: letters/digits/space and common punctuation only.
|
|
102
|
+
const PROVIDER_TYPE_RE = /^[A-Za-z0-9 &.,()/'-]{1,100}$/;
|
|
103
|
+
|
|
104
|
+
const SIZE_MIN = 1;
|
|
105
|
+
const SIZE_MAX = 100;
|
|
106
|
+
const SIZE_DEFAULT = 25;
|
|
107
|
+
|
|
108
|
+
// ─── Honesty notes (ADR-0061 required set) ────────────────────────
|
|
109
|
+
const VINTAGE_NOTE =
|
|
110
|
+
`Source dataset: CMS "Medicare Physician & Other Practitioners — by Provider and Service" (data.cms.gov dataset ${CMS_DATASET_UUID}) — a SPECIFIC ANNUAL VINTAGE (the most recent published year at build time), NOT a live/current or a multi-year figure. Amounts and service counts are as-of that reference year. CMS publishes a new dataset id each year.`;
|
|
111
|
+
const AGGREGATE_NOTE =
|
|
112
|
+
"These are public PROVIDER-level AGGREGATE utilization figures (no patient identifiers). Averages (avgSubmittedCharge / avgMedicareAllowed / avgMedicarePayment) are per-service means for the provider+HCPCS row; totalBeneficiaries is CMS-rounded and suppressed below 11 in the source. This is a utilization snapshot, NOT a fraud, quality, or fitness determination.";
|
|
113
|
+
const COUNT_FALLBACK_NOTE =
|
|
114
|
+
"The count sub-query (…/data-viewer/stats) failed or did not report found_rows, so totalAvailable is null (unknown) — it was NOT faked from the returned row count. hasMore is a heuristic (a full page ⇒ likely more); re-page with offset to confirm.";
|
|
115
|
+
|
|
116
|
+
// ─── Curated provider-service shape ───────────────────────────────
|
|
117
|
+
export type ProviderService = {
|
|
118
|
+
npi: string | null;
|
|
119
|
+
providerName: string | null;
|
|
120
|
+
credentials: string | null;
|
|
121
|
+
providerType: string | null;
|
|
122
|
+
city: string | null;
|
|
123
|
+
state: string | null;
|
|
124
|
+
zip: string | null;
|
|
125
|
+
hcpcsCode: string | null;
|
|
126
|
+
hcpcsDescription: string | null;
|
|
127
|
+
totalBeneficiaries: number | null;
|
|
128
|
+
totalServices: number | null;
|
|
129
|
+
avgSubmittedCharge: number | null;
|
|
130
|
+
avgMedicareAllowed: number | null;
|
|
131
|
+
avgMedicarePayment: number | null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Join the CMS Last_Org_Name + First_Name into one display name. An ORGANIZATION
|
|
136
|
+
* row (entity code "O") carries the org name in Last_Org_Name with an empty
|
|
137
|
+
* First_Name ⇒ just the org name. An INDIVIDUAL carries both ⇒ "Last, First".
|
|
138
|
+
* Either absent ⇒ the present one; both absent ⇒ null (never a fabricated "").
|
|
139
|
+
*/
|
|
140
|
+
export function joinProviderName(lastOrg: unknown, first: unknown): string | null {
|
|
141
|
+
const last = str(lastOrg);
|
|
142
|
+
const firstName = str(first);
|
|
143
|
+
if (last !== null && firstName !== null) return `${last}, ${firstName}`;
|
|
144
|
+
return last ?? firstName ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Map ONE data-API row → the curated provider-service shape. */
|
|
148
|
+
function mapService(row: unknown): ProviderService {
|
|
149
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
150
|
+
return {
|
|
151
|
+
npi: str(r.Rndrng_NPI),
|
|
152
|
+
providerName: joinProviderName(
|
|
153
|
+
r.Rndrng_Prvdr_Last_Org_Name,
|
|
154
|
+
r.Rndrng_Prvdr_First_Name,
|
|
155
|
+
),
|
|
156
|
+
credentials: str(r.Rndrng_Prvdr_Crdntls),
|
|
157
|
+
providerType: str(r.Rndrng_Prvdr_Type),
|
|
158
|
+
city: str(r.Rndrng_Prvdr_City),
|
|
159
|
+
state: str(r.Rndrng_Prvdr_State_Abrvtn),
|
|
160
|
+
zip: str(r.Rndrng_Prvdr_Zip5),
|
|
161
|
+
hcpcsCode: str(r.HCPCS_Cd),
|
|
162
|
+
hcpcsDescription: str(r.HCPCS_Desc),
|
|
163
|
+
totalBeneficiaries: num(r.Tot_Benes),
|
|
164
|
+
totalServices: num(r.Tot_Srvcs),
|
|
165
|
+
avgSubmittedCharge: num(r.Avg_Sbmtd_Chrg),
|
|
166
|
+
avgMedicareAllowed: num(r.Avg_Mdcr_Alowd_Amt),
|
|
167
|
+
avgMedicarePayment: num(r.Avg_Mdcr_Pymt_Amt),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect:"error") ──
|
|
172
|
+
/**
|
|
173
|
+
* GET one data.cms.gov JSON resource at a MODULE-BUILT URL (the dataset UUID + the
|
|
174
|
+
* endpoint path are literals; all user filter VALUES are already carried in the
|
|
175
|
+
* URLSearchParams `query`). Asserts the CONSTRUCTED URL's hostname === the fixed
|
|
176
|
+
* host over https, and sets `redirect:"error"` (an off-host 3xx must NOT be
|
|
177
|
+
* followed). Keyless — no headers.
|
|
178
|
+
*/
|
|
179
|
+
async function getCms(path: string, query: URLSearchParams): Promise<unknown> {
|
|
180
|
+
const url = `https://${CMS_HOST}${path}?${query.toString()}`;
|
|
181
|
+
const built = new URL(url);
|
|
182
|
+
if (built.hostname !== CMS_HOST || built.protocol !== "https:") {
|
|
183
|
+
throw new ToolErrorCarrier({
|
|
184
|
+
kind: "invalid_input",
|
|
185
|
+
message: `Constructed CMS data-API URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(CMS_HOST)} over https — refusing to fetch (SSRF safety).`,
|
|
186
|
+
retryable: false,
|
|
187
|
+
upstreamEndpoint: CMS_LABEL,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return getJson(built.toString(), { label: CMS_LABEL, redirect: "error" });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type CmsMedicareProviderServicesArgs = {
|
|
194
|
+
npi?: string;
|
|
195
|
+
state?: string;
|
|
196
|
+
providerType?: string;
|
|
197
|
+
hcpcsCode?: string;
|
|
198
|
+
size?: number;
|
|
199
|
+
offset?: number;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Fetch Medicare Part-B provider-service utilization rows for an NPI / state (+
|
|
204
|
+
* optional providerType / hcpcsCode) → normalized service rows + honest `_meta`.
|
|
205
|
+
* REQUIRES npi OR state (an all-empty query is refused). Runs a stats count
|
|
206
|
+
* sub-query FIRST for the EXACT total (P1), then the data slice; a count failure
|
|
207
|
+
* degrades to totalAvailable:null + a note (never a length-faked total).
|
|
208
|
+
*/
|
|
209
|
+
export async function providerServices(
|
|
210
|
+
args: CmsMedicareProviderServicesArgs,
|
|
211
|
+
): Promise<MetaBundle> {
|
|
212
|
+
// ── [input guard] require npi OR state (never scan the whole 9.78M-row table).
|
|
213
|
+
// providerType / hcpcsCode alone are NOT sufficient to scope. ──
|
|
214
|
+
const hasNpi = args.npi !== undefined && args.npi !== "";
|
|
215
|
+
const hasState = args.state !== undefined && args.state !== "";
|
|
216
|
+
if (!hasNpi && !hasState) {
|
|
217
|
+
throw new ToolErrorCarrier({
|
|
218
|
+
kind: "invalid_input",
|
|
219
|
+
retryable: false,
|
|
220
|
+
message:
|
|
221
|
+
"cms_medicare_provider_services requires at least `npi` (10-digit) OR `state` (2-letter) — an all-empty query would scan the entire 9.78M-row Medicare utilization table and is refused. providerType/hcpcsCode alone are not enough; add npi or state and retry.",
|
|
222
|
+
upstreamEndpoint: CMS_LABEL,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Validate + build the filter params (SSRF: charclass + URLSearchParams value).
|
|
227
|
+
// URLSearchParams encodes both the bracket key and the value. ──
|
|
228
|
+
const filters = new URLSearchParams();
|
|
229
|
+
const filtersApplied: string[] = [];
|
|
230
|
+
|
|
231
|
+
if (hasNpi) {
|
|
232
|
+
const npi = args.npi as string;
|
|
233
|
+
if (!NPI_RE.test(npi)) {
|
|
234
|
+
throw new ToolErrorCarrier({
|
|
235
|
+
kind: "invalid_input",
|
|
236
|
+
retryable: false,
|
|
237
|
+
message: `Invalid npi ${JSON.stringify(npi)} — expected a 10-digit National Provider Identifier (^\\d{10}$).`,
|
|
238
|
+
upstreamEndpoint: CMS_LABEL,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
filters.set("filter[Rndrng_NPI]", npi);
|
|
242
|
+
filtersApplied.push(`npi:${npi}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (hasState) {
|
|
246
|
+
const state = args.state as string;
|
|
247
|
+
if (!STATE_RE.test(state)) {
|
|
248
|
+
throw new ToolErrorCarrier({
|
|
249
|
+
kind: "invalid_input",
|
|
250
|
+
retryable: false,
|
|
251
|
+
message: `Invalid state ${JSON.stringify(state)} — expected a 2-letter state/territory code (^[A-Za-z]{2}$), e.g. "VA".`,
|
|
252
|
+
upstreamEndpoint: CMS_LABEL,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
filters.set("filter[Rndrng_Prvdr_State_Abrvtn]", state.toUpperCase());
|
|
256
|
+
filtersApplied.push(`state:${state.toUpperCase()}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (args.providerType !== undefined && args.providerType !== "") {
|
|
260
|
+
const pt = args.providerType;
|
|
261
|
+
if (!PROVIDER_TYPE_RE.test(pt)) {
|
|
262
|
+
throw new ToolErrorCarrier({
|
|
263
|
+
kind: "invalid_input",
|
|
264
|
+
retryable: false,
|
|
265
|
+
message: `Invalid providerType ${JSON.stringify(pt)} — allowed: letters, digits, space, & . , ( ) / ' - (≤100 chars).`,
|
|
266
|
+
upstreamEndpoint: CMS_LABEL,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
filters.set("filter[Rndrng_Prvdr_Type]", pt);
|
|
270
|
+
filtersApplied.push(`providerType:${pt}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (args.hcpcsCode !== undefined && args.hcpcsCode !== "") {
|
|
274
|
+
const hcpcs = args.hcpcsCode;
|
|
275
|
+
if (!HCPCS_RE.test(hcpcs)) {
|
|
276
|
+
throw new ToolErrorCarrier({
|
|
277
|
+
kind: "invalid_input",
|
|
278
|
+
retryable: false,
|
|
279
|
+
message: `Invalid hcpcsCode ${JSON.stringify(hcpcs)} — expected an alphanumeric HCPCS/CPT code (^[A-Za-z0-9]{1,10}$), e.g. "97110" or "G0463".`,
|
|
280
|
+
upstreamEndpoint: CMS_LABEL,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
filters.set("filter[HCPCS_Cd]", hcpcs.toUpperCase());
|
|
284
|
+
filtersApplied.push(`hcpcsCode:${hcpcs.toUpperCase()}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Coerce size / offset to bounded integers (belt-and-suspenders behind the
|
|
288
|
+
// server Zod; a DIRECT handler call bypasses Zod). ──
|
|
289
|
+
let size = SIZE_DEFAULT;
|
|
290
|
+
if (typeof args.size === "number" && Number.isFinite(args.size)) {
|
|
291
|
+
size = Math.trunc(args.size);
|
|
292
|
+
if (size < SIZE_MIN) size = SIZE_MIN;
|
|
293
|
+
if (size > SIZE_MAX) size = SIZE_MAX;
|
|
294
|
+
}
|
|
295
|
+
let offset = 0;
|
|
296
|
+
if (typeof args.offset === "number" && Number.isFinite(args.offset) && args.offset > 0) {
|
|
297
|
+
offset = Math.trunc(args.offset);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const dataBase = `/data-api/v1/dataset/${CMS_DATASET_UUID}/data`;
|
|
301
|
+
const statsPath = `/data-api/v1/dataset/${CMS_DATASET_UUID}/data-viewer/stats`;
|
|
302
|
+
|
|
303
|
+
// ── (1) The COUNT sub-query FIRST (best-effort — the EXACT total, P1). The stats
|
|
304
|
+
// body is `{ data: { found_rows, total_rows } }`. Any failure (network/5xx/
|
|
305
|
+
// drift/missing field) degrades to totalAvailable:null + a disclosing note; it
|
|
306
|
+
// NEVER throws and NEVER fakes the total from the slice length. ──
|
|
307
|
+
let totalAvailable: number | null = null;
|
|
308
|
+
let countFailed = false;
|
|
309
|
+
try {
|
|
310
|
+
const statsBody = await getCms(statsPath, filters);
|
|
311
|
+
const dataObj =
|
|
312
|
+
statsBody !== null &&
|
|
313
|
+
typeof statsBody === "object" &&
|
|
314
|
+
typeof (statsBody as Record<string, unknown>).data === "object" &&
|
|
315
|
+
(statsBody as Record<string, unknown>).data !== null
|
|
316
|
+
? ((statsBody as Record<string, unknown>).data as Record<string, unknown>)
|
|
317
|
+
: undefined;
|
|
318
|
+
if (dataObj !== undefined) {
|
|
319
|
+
const t = num(dataObj.found_rows);
|
|
320
|
+
if (t !== null && t >= 0) {
|
|
321
|
+
totalAvailable = t;
|
|
322
|
+
} else {
|
|
323
|
+
countFailed = true; // present body but no usable found_rows (P4)
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
countFailed = true; // stats body not the expected { data: {…} } shape
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
countFailed = true; // any count error ⇒ degrade, never propagate (P1)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── (2) The DATA slice — the authoritative request (a bare JSON array). ──
|
|
333
|
+
const dataQuery = new URLSearchParams(filters);
|
|
334
|
+
dataQuery.set("size", String(size));
|
|
335
|
+
dataQuery.set("offset", String(offset));
|
|
336
|
+
|
|
337
|
+
// Catch-ladder (epa-envirofacts / datagov-catalog shape): preserve the
|
|
338
|
+
// 4xx/5xx/timeout ToolErrorCarrier taxonomy FIRST; reclassify a 200 non-JSON
|
|
339
|
+
// SyntaxError to schema_drift SECOND; bare-rethrow LAST.
|
|
340
|
+
let body: unknown;
|
|
341
|
+
try {
|
|
342
|
+
body = await getCms(dataBase, dataQuery);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
345
|
+
if (e instanceof SyntaxError)
|
|
346
|
+
throw driftError(
|
|
347
|
+
CMS_LABEL,
|
|
348
|
+
"CMS data-API returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
|
|
349
|
+
);
|
|
350
|
+
throw e;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// [P4] the data body MUST be an array (a non-array 200 is drift, never a
|
|
354
|
+
// fabricated empty).
|
|
355
|
+
if (!Array.isArray(body)) {
|
|
356
|
+
throw driftError(
|
|
357
|
+
CMS_LABEL,
|
|
358
|
+
"CMS data-API shape drift — the /data response must be a JSON array of provider-service rows.",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const services = (body as unknown[]).map(mapService);
|
|
363
|
+
const returned = services.length;
|
|
364
|
+
|
|
365
|
+
// ── [P1] pagination. total known ⇒ hasMore = offset+returned < total; total
|
|
366
|
+
// unknown (count degraded) ⇒ a full page is the honest heuristic for "more". ──
|
|
367
|
+
const hasMore =
|
|
368
|
+
totalAvailable !== null
|
|
369
|
+
? offset + returned < totalAvailable
|
|
370
|
+
: returned === size;
|
|
371
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
372
|
+
|
|
373
|
+
const notes: string[] = [VINTAGE_NOTE, AGGREGATE_NOTE];
|
|
374
|
+
if (countFailed) notes.push(COUNT_FALLBACK_NOTE);
|
|
375
|
+
|
|
376
|
+
const meta: Partial<ResponseMeta> = {
|
|
377
|
+
source: `${CMS_HOST} CMS Medicare Physician & Other Practitioners — by Provider and Service (keyless)`,
|
|
378
|
+
keylessMode: true,
|
|
379
|
+
returned,
|
|
380
|
+
totalAvailable,
|
|
381
|
+
filtersApplied,
|
|
382
|
+
filtersDropped: [],
|
|
383
|
+
fieldsUnavailable: [],
|
|
384
|
+
pagination: { offset, limit: size, hasMore, nextOffset },
|
|
385
|
+
notes,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
return withMeta({ services }, meta);
|
|
389
|
+
}
|