@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.
Files changed (91) hide show
  1. package/README.ja.md +22 -9
  2. package/README.ko.md +22 -9
  3. package/README.md +70 -12
  4. package/dist/bea.d.ts +105 -0
  5. package/dist/bea.d.ts.map +1 -0
  6. package/dist/bea.js +303 -0
  7. package/dist/bea.js.map +1 -0
  8. package/dist/census-economic.d.ts +1 -1
  9. package/dist/census-economic.d.ts.map +1 -1
  10. package/dist/census-economic.js +12 -6
  11. package/dist/census-economic.js.map +1 -1
  12. package/dist/cms-facility.d.ts +112 -0
  13. package/dist/cms-facility.d.ts.map +1 -0
  14. package/dist/cms-facility.js +311 -0
  15. package/dist/cms-facility.js.map +1 -0
  16. package/dist/cms-hospital.d.ts +105 -0
  17. package/dist/cms-hospital.d.ts.map +1 -0
  18. package/dist/cms-hospital.js +290 -0
  19. package/dist/cms-hospital.js.map +1 -0
  20. package/dist/cms-supplier.d.ts +133 -0
  21. package/dist/cms-supplier.d.ts.map +1 -0
  22. package/dist/cms-supplier.js +414 -0
  23. package/dist/cms-supplier.js.map +1 -0
  24. package/dist/cms-utilization.d.ts +113 -0
  25. package/dist/cms-utilization.d.ts.map +1 -0
  26. package/dist/cms-utilization.js +328 -0
  27. package/dist/cms-utilization.js.map +1 -0
  28. package/dist/courtlistener.d.ts +115 -0
  29. package/dist/courtlistener.d.ts.map +1 -0
  30. package/dist/courtlistener.js +398 -0
  31. package/dist/courtlistener.js.map +1 -0
  32. package/dist/cpsc.d.ts +81 -0
  33. package/dist/cpsc.d.ts.map +1 -0
  34. package/dist/cpsc.js +283 -0
  35. package/dist/cpsc.js.map +1 -0
  36. package/dist/dol.d.ts +118 -0
  37. package/dist/dol.d.ts.map +1 -0
  38. package/dist/dol.js +421 -0
  39. package/dist/dol.js.map +1 -0
  40. package/dist/epa-envirofacts.d.ts +97 -0
  41. package/dist/epa-envirofacts.d.ts.map +1 -0
  42. package/dist/epa-envirofacts.js +292 -0
  43. package/dist/epa-envirofacts.js.map +1 -0
  44. package/dist/fred.d.ts +1 -1
  45. package/dist/fred.js +1 -1
  46. package/dist/keys.d.ts +11 -8
  47. package/dist/keys.d.ts.map +1 -1
  48. package/dist/keys.js +55 -8
  49. package/dist/keys.js.map +1 -1
  50. package/dist/lda.d.ts +105 -0
  51. package/dist/lda.d.ts.map +1 -0
  52. package/dist/lda.js +317 -0
  53. package/dist/lda.js.map +1 -0
  54. package/dist/nhtsa.d.ts +91 -0
  55. package/dist/nhtsa.d.ts.map +1 -0
  56. package/dist/nhtsa.js +263 -0
  57. package/dist/nhtsa.js.map +1 -0
  58. package/dist/nonprofit.d.ts +116 -0
  59. package/dist/nonprofit.d.ts.map +1 -0
  60. package/dist/nonprofit.js +342 -0
  61. package/dist/nonprofit.js.map +1 -0
  62. package/dist/openfda-device.d.ts +85 -0
  63. package/dist/openfda-device.d.ts.map +1 -0
  64. package/dist/openfda-device.js +277 -0
  65. package/dist/openfda-device.js.map +1 -0
  66. package/dist/openfda.d.ts +133 -0
  67. package/dist/openfda.d.ts.map +1 -0
  68. package/dist/openfda.js +402 -0
  69. package/dist/openfda.js.map +1 -0
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +872 -6
  72. package/dist/server.js.map +1 -1
  73. package/package.json +2 -1
  74. package/src/bea.ts +372 -0
  75. package/src/census-economic.ts +12 -6
  76. package/src/cms-facility.ts +379 -0
  77. package/src/cms-hospital.ts +344 -0
  78. package/src/cms-supplier.ts +527 -0
  79. package/src/cms-utilization.ts +389 -0
  80. package/src/courtlistener.ts +465 -0
  81. package/src/cpsc.ts +333 -0
  82. package/src/dol.ts +515 -0
  83. package/src/epa-envirofacts.ts +342 -0
  84. package/src/fred.ts +1 -1
  85. package/src/keys.ts +60 -8
  86. package/src/lda.ts +385 -0
  87. package/src/nhtsa.ts +352 -0
  88. package/src/nonprofit.ts +460 -0
  89. package/src/openfda-device.ts +356 -0
  90. package/src/openfda.ts +495 -0
  91. package/src/server.ts +995 -6
@@ -0,0 +1,344 @@
1
+ /**
2
+ * cms-hospital.ts — CMS Hospital Compare "Hospital General Information"
3
+ * (`data.cms.gov`, the provider-data DKAN datastore-query API; ADR-0062). KEYLESS.
4
+ *
5
+ * WHAT IT ADDS: `cms_hospital_compare` — a healthcare-facility directory / market
6
+ * lane: for a US state (and/or a facility-name fragment) list Medicare-certified
7
+ * hospitals with their location, type, ownership, emergency-services flag, and CMS
8
+ * star rating. The facility-level complement to the utilization lane
9
+ * (cms_medicare_provider_services, who BILLS) — this is WHERE the hospitals ARE and
10
+ * HOW CMS rates them.
11
+ *
12
+ * ★THE ONE-REQUEST COUNT PATTERN (the load-bearing P1 honesty — SIMPLER than
13
+ * cms-utilization's two-request stats-count): the DKAN datastore-query response
14
+ * is `{ count, results, schema, query }` — `count` is the EXACT per-filter total
15
+ * in the SAME body as the rows. So totalAvailable = the response's top-level
16
+ * `count` (VA ⇒ 96), NEVER `results.length`. A single request; no separate count
17
+ * sub-query is needed (or possible — the endpoint reports the total inline).
18
+ *
19
+ * ★THE FILTER-REQUIRED INPUT GUARD: the dataset is 5432 hospitals. An all-empty
20
+ * query (no state, no facilityName) is REFUSED with invalid_input (0 fetch) —
21
+ * hospitalType alone is NOT enough to scope; a caller MUST pin state OR
22
+ * facilityName.
23
+ *
24
+ * ★THE DKAN CONDITIONS FILTER (live-verified 2026-07-15): filters ride as
25
+ * `conditions[i][property]` / `conditions[i][value]` / `conditions[i][operator]`
26
+ * query triples, AND-combined server-side. `state` uses the exact operator `=`
27
+ * (VA ⇒ 96); `facilityName` + `hospitalType` use the `contains` operator (a
28
+ * case-insensitive substring match, live-verified — "CHILDREN" ⇒ 95). ALL
29
+ * filtering is server-side; NOTHING is silently dropped or client-faked.
30
+ *
31
+ * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
32
+ * (redirect:"error") / `driftError` (datasource.ts), `str`/`num` (coerce.ts,
33
+ * null-never-empty-string / null-never-0), and `withMeta`·`buildMeta` (meta.ts,
34
+ * offset pagination + totalAvailable). It MIRRORS cms-utilization.ts's fixed-host
35
+ * SSRF idiom (a single host const + a post-construction hostname/protocol assertion
36
+ * + redirect:"error" + `conditions[i][…]` bracket keys and values carried via
37
+ * URLSearchParams) and its schema_drift catch-ladder (ToolErrorCarrier rethrow
38
+ * FIRST so a 5xx keeps its taxonomy → SyntaxError→driftError → bare rethrow).
39
+ *
40
+ * GET https://data.cms.gov/provider-data/api/1/datastore/query/{datasetId}/0
41
+ * ?limit=&offset=&conditions[0][property]=state&conditions[0][value]=VA&conditions[0][operator]==
42
+ * → { count: 96, results: [ { facility_id, facility_name, … }, … ], schema, query }
43
+ *
44
+ * ★ SSRF: the host is a compile-time literal (`CMS_HOST`); the dataset id + the
45
+ * endpoint path are MODULE literals. Every USER filter VALUE rides as a
46
+ * URLSearchParams VALUE (`conditions[i][value]=…`) — URLSearchParams encodes the
47
+ * bracket key AND the value, so a value can never break out into the path or
48
+ * inject a parameter. state is `^[A-Za-z]{2}$`; facilityName/hospitalType are a
49
+ * bounded free-text charclass; size/offset are coerced to integers. A
50
+ * post-construction hostname/protocol assertion + `redirect:"error"` fail closed
51
+ * on any off-host 3xx.
52
+ *
53
+ * ★ HONESTY (ADR-0062 P1–P5, live-verified 2026-07-15 on data.cms.gov):
54
+ * [input] require state OR facilityName — an all-empty query is REFUSED (0 fetch).
55
+ * [P1] totalAvailable = the response's top-level `count` (EXACT — VA = 96), NOT
56
+ * the slice length. hasMore = offset+returned < count.
57
+ * [P2] results:[] ⇒ honest empty (returned:0). getJson maps a 4xx/5xx via
58
+ * errorFromResponse and THROWS (503 ⇒ upstream_unavailable, 400 ⇒
59
+ * invalid_input, 404 ⇒ not_found); a 200 non-JSON/non-array body OR a body
60
+ * missing `count`/`results` ⇒ schema_drift (NEVER a fabricated empty).
61
+ * [P3] hospital_overall_rating "1"–"5" via num(); "Not Available"/""/non-numeric
62
+ * ⇒ null (NEVER 0 — a data-absence-as-zero masquerade is the forbidden
63
+ * class). emergency_services "Yes"⇒true / "No"⇒false / else null. String
64
+ * fields via str() (null-never-empty-string).
65
+ * [P4] results non-array OR count non-number ⇒ driftError.
66
+ */
67
+
68
+ import { ToolErrorCarrier } from "./errors.js";
69
+ import { getJson, driftError } from "./datasource.js";
70
+ import { str, num } from "./coerce.js";
71
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
72
+
73
+ // Re-export the shared honesty coercions (single audited copy in ./coerce.js) so a
74
+ // regression fails together across sources. NO local num/str.
75
+ export { num, str };
76
+
77
+ // ─── SSRF core: the single fixed host + module-literal path pieces ──
78
+ const CMS_HOST = "data.cms.gov";
79
+ // HOST-only label — surfaces in ToolError.upstreamEndpoint; keyless, so no token
80
+ // can ever appear here.
81
+ const CMS_LABEL = "cms-hospital:data.cms.gov";
82
+
83
+ // ★THE DATASET ID — CMS Hospital Compare "Hospital General Information" (5432
84
+ // Medicare-certified hospitals at build time). ★UPDATE IF CMS RE-IDs IT: the
85
+ // provider-data datastore keys this dataset by a short slug; CMS has historically
86
+ // kept it stable, but a re-publish could change it. The active dataset is surfaced
87
+ // to the caller in a _meta note (DATASET_NOTE) so a consumer never mistakes the
88
+ // vintage.
89
+ const CMS_HOSPITAL_DATASET_ID = "xubh-q36u";
90
+ const CMS_HOSPITAL_PATH = `/provider-data/api/1/datastore/query/${CMS_HOSPITAL_DATASET_ID}/0`;
91
+
92
+ // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
93
+ const STATE_RE = /^[A-Za-z]{2}$/; // 2-letter state/territory abbreviation
94
+ // facilityName / hospitalType ride as URLSearchParams VALUES (encoded), so these
95
+ // bounds are a sanity guard, not an SSRF necessity: letters/digits/space and common
96
+ // punctuation only (facility names carry apostrophes, &, /, -, .).
97
+ const FACILITY_NAME_RE = /^[A-Za-z0-9 &.,()/'-]{1,100}$/;
98
+ const HOSPITAL_TYPE_RE = /^[A-Za-z0-9 &.,()/'-]{1,100}$/;
99
+
100
+ const SIZE_MIN = 1;
101
+ const SIZE_MAX = 100;
102
+ const SIZE_DEFAULT = 25;
103
+
104
+ // DKAN condition operators (live-verified). state = exact; name/type = substring.
105
+ const OP_EQUALS = "=";
106
+ const OP_CONTAINS = "contains";
107
+
108
+ // ─── Honesty notes (ADR-0062 required set) ────────────────────────
109
+ const DATASET_NOTE =
110
+ `Source dataset: CMS Hospital Compare "Hospital General Information" (data.cms.gov provider-data datastore "${CMS_HOSPITAL_DATASET_ID}", ~5,432 Medicare-certified hospitals) — a CMS-published snapshot, not a live/real-time feed. Update the dataset id if CMS re-publishes it.`;
111
+ const FILTER_NOTE =
112
+ "Filters are applied SERVER-SIDE via DKAN conditions (AND-combined): `state` is an EXACT match; `facilityName` and `hospitalType` are case-insensitive SUBSTRING (contains) matches — so a facilityName fragment may match several hospitals. totalAvailable is the upstream's EXACT count for this filter set, not the returned-row count.";
113
+ const RATING_NOTE =
114
+ "hospital_overall_rating is CMS's 1–5 star summary rating; \"Not Available\" (and any non-numeric) maps to overallRating:null (unknown), NEVER 0. This is a summary rating, NOT a clinical-quality or fitness determination.";
115
+
116
+ // ─── Curated hospital shape ───────────────────────────────────────
117
+ export type Hospital = {
118
+ facilityId: string | null;
119
+ facilityName: string | null;
120
+ address: string | null;
121
+ city: string | null;
122
+ state: string | null;
123
+ zip: string | null;
124
+ county: string | null;
125
+ phone: string | null;
126
+ hospitalType: string | null;
127
+ ownership: string | null;
128
+ emergencyServices: boolean | null;
129
+ overallRating: number | null;
130
+ };
131
+
132
+ /**
133
+ * Normalize the CMS `emergency_services` "Yes"/"No" flag → boolean | null.
134
+ * "Yes"⇒true, "No"⇒false (case-insensitive); anything else (absent/""/unrecognized)
135
+ * ⇒ null — NEVER a fabricated false (a data-absence-as-false masquerade).
136
+ */
137
+ export function emergencyBool(x: unknown): boolean | null {
138
+ const s = str(x);
139
+ if (s === null) return null;
140
+ const low = s.toLowerCase();
141
+ if (low === "yes") return true;
142
+ if (low === "no") return false;
143
+ return null;
144
+ }
145
+
146
+ /** Map ONE datastore-query row → the curated hospital shape. */
147
+ function mapHospital(row: unknown): Hospital {
148
+ const r = (row ?? {}) as Record<string, unknown>;
149
+ return {
150
+ facilityId: str(r.facility_id),
151
+ facilityName: str(r.facility_name),
152
+ address: str(r.address),
153
+ city: str(r.citytown),
154
+ state: str(r.state),
155
+ zip: str(r.zip_code),
156
+ county: str(r.countyparish),
157
+ phone: str(r.telephone_number),
158
+ hospitalType: str(r.hospital_type),
159
+ ownership: str(r.hospital_ownership),
160
+ emergencyServices: emergencyBool(r.emergency_services),
161
+ // [P3] "1"–"5" → number; "Not Available"/""/non-numeric → null (NEVER 0).
162
+ // num() returns null for a non-numeric string (Number("Not Available")=NaN) and
163
+ // for "" — exactly the P3 contract.
164
+ overallRating: num(r.hospital_overall_rating),
165
+ };
166
+ }
167
+
168
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect:"error") ──
169
+ /**
170
+ * GET one data.cms.gov provider-data JSON resource at a MODULE-BUILT URL (the
171
+ * dataset id + the endpoint path are literals; all user filter VALUES are already
172
+ * carried in the URLSearchParams `query`). Asserts the CONSTRUCTED URL's hostname
173
+ * === the fixed host over https, and sets `redirect:"error"` (an off-host 3xx must
174
+ * NOT be followed). Keyless — no headers.
175
+ */
176
+ async function getCms(query: URLSearchParams): Promise<unknown> {
177
+ const url = `https://${CMS_HOST}${CMS_HOSPITAL_PATH}?${query.toString()}`;
178
+ const built = new URL(url);
179
+ if (built.hostname !== CMS_HOST || built.protocol !== "https:") {
180
+ throw new ToolErrorCarrier({
181
+ kind: "invalid_input",
182
+ message: `Constructed CMS provider-data 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).`,
183
+ retryable: false,
184
+ upstreamEndpoint: CMS_LABEL,
185
+ });
186
+ }
187
+ return getJson(built.toString(), { label: CMS_LABEL, redirect: "error" });
188
+ }
189
+
190
+ export type CmsHospitalCompareArgs = {
191
+ state?: string;
192
+ facilityName?: string;
193
+ hospitalType?: string;
194
+ size?: number;
195
+ offset?: number;
196
+ };
197
+
198
+ /**
199
+ * Fetch CMS Hospital Compare "Hospital General Information" rows for a state and/or
200
+ * facility-name fragment (+ optional hospitalType) → normalized hospital rows +
201
+ * honest `_meta`. REQUIRES state OR facilityName (an all-empty query is refused). A
202
+ * SINGLE request: the response's top-level `count` is the EXACT per-filter total
203
+ * (P1 — never the slice length).
204
+ */
205
+ export async function hospitalCompare(
206
+ args: CmsHospitalCompareArgs,
207
+ ): Promise<MetaBundle> {
208
+ // ── [input guard] require state OR facilityName (never scan the whole dataset).
209
+ // hospitalType alone is NOT sufficient to scope. ──
210
+ const hasState = args.state !== undefined && args.state !== "";
211
+ const hasFacilityName =
212
+ args.facilityName !== undefined && args.facilityName !== "";
213
+ if (!hasState && !hasFacilityName) {
214
+ throw new ToolErrorCarrier({
215
+ kind: "invalid_input",
216
+ retryable: false,
217
+ message:
218
+ "cms_hospital_compare requires at least `state` (2-letter) OR `facilityName` — an all-empty query would scan the entire ~5,432-hospital table and is refused. hospitalType alone is not enough; add state or facilityName and retry.",
219
+ upstreamEndpoint: CMS_LABEL,
220
+ });
221
+ }
222
+
223
+ // ── Validate + build the DKAN conditions (SSRF: charclass + URLSearchParams
224
+ // value). URLSearchParams encodes both the bracket key and the value. Each
225
+ // condition is an (property, value, operator) triple indexed by position. ──
226
+ const query = new URLSearchParams();
227
+ const filtersApplied: string[] = [];
228
+ let ci = 0;
229
+ const pushCondition = (property: string, value: string, operator: string) => {
230
+ query.set(`conditions[${ci}][property]`, property);
231
+ query.set(`conditions[${ci}][value]`, value);
232
+ query.set(`conditions[${ci}][operator]`, operator);
233
+ ci += 1;
234
+ };
235
+
236
+ if (hasState) {
237
+ const state = args.state as string;
238
+ if (!STATE_RE.test(state)) {
239
+ throw new ToolErrorCarrier({
240
+ kind: "invalid_input",
241
+ retryable: false,
242
+ message: `Invalid state ${JSON.stringify(state)} — expected a 2-letter state/territory code (^[A-Za-z]{2}$), e.g. "VA".`,
243
+ upstreamEndpoint: CMS_LABEL,
244
+ });
245
+ }
246
+ pushCondition("state", state.toUpperCase(), OP_EQUALS);
247
+ filtersApplied.push(`state:${state.toUpperCase()}`);
248
+ }
249
+
250
+ if (hasFacilityName) {
251
+ const name = args.facilityName as string;
252
+ if (!FACILITY_NAME_RE.test(name)) {
253
+ throw new ToolErrorCarrier({
254
+ kind: "invalid_input",
255
+ retryable: false,
256
+ message: `Invalid facilityName ${JSON.stringify(name)} — allowed: letters, digits, space, & . , ( ) / ' - (≤100 chars). It is a case-insensitive substring match.`,
257
+ upstreamEndpoint: CMS_LABEL,
258
+ });
259
+ }
260
+ pushCondition("facility_name", name, OP_CONTAINS);
261
+ filtersApplied.push(`facilityName~${name}`);
262
+ }
263
+
264
+ if (args.hospitalType !== undefined && args.hospitalType !== "") {
265
+ const ht = args.hospitalType;
266
+ if (!HOSPITAL_TYPE_RE.test(ht)) {
267
+ throw new ToolErrorCarrier({
268
+ kind: "invalid_input",
269
+ retryable: false,
270
+ message: `Invalid hospitalType ${JSON.stringify(ht)} — allowed: letters, digits, space, & . , ( ) / ' - (≤100 chars). It is a case-insensitive substring match, e.g. "Acute".`,
271
+ upstreamEndpoint: CMS_LABEL,
272
+ });
273
+ }
274
+ pushCondition("hospital_type", ht, OP_CONTAINS);
275
+ filtersApplied.push(`hospitalType~${ht}`);
276
+ }
277
+
278
+ // ── Coerce size / offset to bounded integers (belt-and-suspenders behind the
279
+ // server Zod; a DIRECT handler call bypasses Zod). ──
280
+ let size = SIZE_DEFAULT;
281
+ if (typeof args.size === "number" && Number.isFinite(args.size)) {
282
+ size = Math.trunc(args.size);
283
+ if (size < SIZE_MIN) size = SIZE_MIN;
284
+ if (size > SIZE_MAX) size = SIZE_MAX;
285
+ }
286
+ let offset = 0;
287
+ if (
288
+ typeof args.offset === "number" &&
289
+ Number.isFinite(args.offset) &&
290
+ args.offset > 0
291
+ ) {
292
+ offset = Math.trunc(args.offset);
293
+ }
294
+ query.set("limit", String(size));
295
+ query.set("offset", String(offset));
296
+
297
+ // ── The single request. Catch-ladder (cms-utilization shape): preserve the
298
+ // 4xx/5xx/timeout ToolErrorCarrier taxonomy FIRST; reclassify a 200 non-JSON
299
+ // SyntaxError to schema_drift SECOND; bare-rethrow LAST. ──
300
+ let body: unknown;
301
+ try {
302
+ body = await getCms(query);
303
+ } catch (e) {
304
+ if (e instanceof ToolErrorCarrier) throw e;
305
+ if (e instanceof SyntaxError)
306
+ throw driftError(
307
+ CMS_LABEL,
308
+ "CMS provider-data API returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
309
+ );
310
+ throw e;
311
+ }
312
+
313
+ // [P4] the body MUST be an object with a numeric `count` and an array `results`
314
+ // (a non-array results OR a non-number count is drift, never a fabricated empty).
315
+ const b = (body ?? {}) as { count?: unknown; results?: unknown };
316
+ if (typeof b.count !== "number" || !Number.isFinite(b.count) || !Array.isArray(b.results)) {
317
+ throw driftError(
318
+ CMS_LABEL,
319
+ "CMS provider-data shape drift — the response must carry a numeric `count` and an array `results` of hospital rows.",
320
+ );
321
+ }
322
+
323
+ const totalAvailable = b.count;
324
+ const hospitals = (b.results as unknown[]).map(mapHospital);
325
+ const returned = hospitals.length;
326
+
327
+ // ── [P1] pagination. hasMore = offset+returned < total (the EXACT count). ──
328
+ const hasMore = offset + returned < totalAvailable;
329
+ const nextOffset = hasMore ? offset + returned : null;
330
+
331
+ const meta: Partial<ResponseMeta> = {
332
+ source: `${CMS_HOST} CMS Hospital Compare — Hospital General Information (keyless)`,
333
+ keylessMode: true,
334
+ returned,
335
+ totalAvailable,
336
+ filtersApplied,
337
+ filtersDropped: [],
338
+ fieldsUnavailable: [],
339
+ pagination: { offset, limit: size, hasMore, nextOffset },
340
+ notes: [DATASET_NOTE, FILTER_NOTE, RATING_NOTE],
341
+ };
342
+
343
+ return withMeta({ hospitals }, meta);
344
+ }