@cliwant/mcp-sam-gov 1.3.0 → 1.5.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 (149) hide show
  1. package/README.ja.md +20 -12
  2. package/README.ko.md +20 -12
  3. package/README.md +62 -14
  4. package/dist/bea.d.ts +1 -1
  5. package/dist/bea.js +1 -1
  6. package/dist/cbp-border.d.ts +51 -0
  7. package/dist/cbp-border.d.ts.map +1 -0
  8. package/dist/cbp-border.js +123 -0
  9. package/dist/cbp-border.js.map +1 -0
  10. package/dist/census-economic.d.ts +1 -1
  11. package/dist/census-economic.d.ts.map +1 -1
  12. package/dist/census-economic.js +12 -6
  13. package/dist/census-economic.js.map +1 -1
  14. package/dist/cms-facility.d.ts +112 -0
  15. package/dist/cms-facility.d.ts.map +1 -0
  16. package/dist/cms-facility.js +311 -0
  17. package/dist/cms-facility.js.map +1 -0
  18. package/dist/cms-hospital.d.ts +105 -0
  19. package/dist/cms-hospital.d.ts.map +1 -0
  20. package/dist/cms-hospital.js +290 -0
  21. package/dist/cms-hospital.js.map +1 -0
  22. package/dist/cms-supplier.d.ts +133 -0
  23. package/dist/cms-supplier.d.ts.map +1 -0
  24. package/dist/cms-supplier.js +414 -0
  25. package/dist/cms-supplier.js.map +1 -0
  26. package/dist/cms-utilization.d.ts +113 -0
  27. package/dist/cms-utilization.d.ts.map +1 -0
  28. package/dist/cms-utilization.js +328 -0
  29. package/dist/cms-utilization.js.map +1 -0
  30. package/dist/courtlistener.d.ts +115 -0
  31. package/dist/courtlistener.d.ts.map +1 -0
  32. package/dist/courtlistener.js +398 -0
  33. package/dist/courtlistener.js.map +1 -0
  34. package/dist/cpsc.d.ts +81 -0
  35. package/dist/cpsc.d.ts.map +1 -0
  36. package/dist/cpsc.js +283 -0
  37. package/dist/cpsc.js.map +1 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -1
  39. package/dist/datagov-catalog.js +16 -2
  40. package/dist/datagov-catalog.js.map +1 -1
  41. package/dist/dol.d.ts +2 -2
  42. package/dist/dol.js +5 -5
  43. package/dist/dol.js.map +1 -1
  44. package/dist/ecfr.d.ts +2 -2
  45. package/dist/ecfr.d.ts.map +1 -1
  46. package/dist/ecfr.js +24 -10
  47. package/dist/ecfr.js.map +1 -1
  48. package/dist/edgar.d.ts.map +1 -1
  49. package/dist/edgar.js +26 -6
  50. package/dist/edgar.js.map +1 -1
  51. package/dist/epa-envirofacts.d.ts +97 -0
  52. package/dist/epa-envirofacts.d.ts.map +1 -0
  53. package/dist/epa-envirofacts.js +305 -0
  54. package/dist/epa-envirofacts.js.map +1 -0
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +11 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/far.d.ts.map +1 -1
  59. package/dist/far.js +3 -1
  60. package/dist/far.js.map +1 -1
  61. package/dist/federal-register.d.ts +2 -2
  62. package/dist/federal-register.d.ts.map +1 -1
  63. package/dist/federal-register.js +26 -10
  64. package/dist/federal-register.js.map +1 -1
  65. package/dist/fema.d.ts +36 -0
  66. package/dist/fema.d.ts.map +1 -1
  67. package/dist/fema.js +124 -0
  68. package/dist/fema.js.map +1 -1
  69. package/dist/fred.d.ts +1 -1
  70. package/dist/fred.js +1 -1
  71. package/dist/gov-domains.d.ts +66 -0
  72. package/dist/gov-domains.d.ts.map +1 -0
  73. package/dist/gov-domains.js +211 -0
  74. package/dist/gov-domains.js.map +1 -0
  75. package/dist/keys.d.ts +6 -5
  76. package/dist/keys.d.ts.map +1 -1
  77. package/dist/keys.js +25 -6
  78. package/dist/keys.js.map +1 -1
  79. package/dist/nhtsa.d.ts +91 -0
  80. package/dist/nhtsa.d.ts.map +1 -0
  81. package/dist/nhtsa.js +263 -0
  82. package/dist/nhtsa.js.map +1 -0
  83. package/dist/nist-controls.d.ts +48 -0
  84. package/dist/nist-controls.d.ts.map +1 -0
  85. package/dist/nist-controls.js +174 -0
  86. package/dist/nist-controls.js.map +1 -0
  87. package/dist/nonprofit.d.ts +116 -0
  88. package/dist/nonprofit.d.ts.map +1 -0
  89. package/dist/nonprofit.js +342 -0
  90. package/dist/nonprofit.js.map +1 -0
  91. package/dist/nws-weather.d.ts +57 -0
  92. package/dist/nws-weather.d.ts.map +1 -0
  93. package/dist/nws-weather.js +131 -0
  94. package/dist/nws-weather.js.map +1 -0
  95. package/dist/openfda-device.d.ts +85 -0
  96. package/dist/openfda-device.d.ts.map +1 -0
  97. package/dist/openfda-device.js +277 -0
  98. package/dist/openfda-device.js.map +1 -0
  99. package/dist/openfda-drugsfda.d.ts +72 -0
  100. package/dist/openfda-drugsfda.d.ts.map +1 -0
  101. package/dist/openfda-drugsfda.js +230 -0
  102. package/dist/openfda-drugsfda.js.map +1 -0
  103. package/dist/openfda.d.ts +133 -0
  104. package/dist/openfda.d.ts.map +1 -0
  105. package/dist/openfda.js +425 -0
  106. package/dist/openfda.js.map +1 -0
  107. package/dist/server.d.ts.map +1 -1
  108. package/dist/server.js +996 -16
  109. package/dist/server.js.map +1 -1
  110. package/dist/treasury.d.ts +2 -0
  111. package/dist/treasury.d.ts.map +1 -1
  112. package/dist/treasury.js +7 -0
  113. package/dist/treasury.js.map +1 -1
  114. package/dist/usaspending.d.ts +32 -1
  115. package/dist/usaspending.d.ts.map +1 -1
  116. package/dist/usaspending.js +143 -16
  117. package/dist/usaspending.js.map +1 -1
  118. package/package.json +3 -2
  119. package/src/bea.ts +1 -1
  120. package/src/cbp-border.ts +177 -0
  121. package/src/census-economic.ts +12 -6
  122. package/src/cms-facility.ts +379 -0
  123. package/src/cms-hospital.ts +344 -0
  124. package/src/cms-supplier.ts +527 -0
  125. package/src/cms-utilization.ts +389 -0
  126. package/src/courtlistener.ts +465 -0
  127. package/src/cpsc.ts +333 -0
  128. package/src/datagov-catalog.ts +18 -2
  129. package/src/dol.ts +5 -5
  130. package/src/ecfr.ts +27 -10
  131. package/src/edgar.ts +39 -7
  132. package/src/epa-envirofacts.ts +358 -0
  133. package/src/errors.ts +11 -0
  134. package/src/far.ts +3 -1
  135. package/src/federal-register.ts +29 -10
  136. package/src/fema.ts +139 -0
  137. package/src/fred.ts +1 -1
  138. package/src/gov-domains.ts +237 -0
  139. package/src/keys.ts +27 -6
  140. package/src/nhtsa.ts +352 -0
  141. package/src/nist-controls.ts +219 -0
  142. package/src/nonprofit.ts +460 -0
  143. package/src/nws-weather.ts +167 -0
  144. package/src/openfda-device.ts +356 -0
  145. package/src/openfda-drugsfda.ts +313 -0
  146. package/src/openfda.ts +518 -0
  147. package/src/server.ts +1127 -27
  148. package/src/treasury.ts +7 -0
  149. package/src/usaspending.ts +189 -17
@@ -0,0 +1,219 @@
1
+ /**
2
+ * nist-controls.ts — NIST SP 800-53 Rev 5 security & privacy CONTROLS catalog
3
+ * (OSCAL, keyless). The cyber-compliance controls backbone for FedRAMP / CMMC / RMF
4
+ * work: look up a control (AC-2, SC-7, …) or a family (Access Control, System &
5
+ * Communications Protection, …) and get its title, requirement STATEMENT, discussion
6
+ * guidance, and control enhancements. No other tool here exposes the controls catalog
7
+ * (we have NVD CVEs + CISA KEV, but not the requirement side).
8
+ *
9
+ * SOURCE: NIST's OFFICIAL OSCAL content, published at github.com/usnistgov/oscal-content
10
+ * (the canonical machine-readable release; the .gov PDF is the human copy). NOT a
11
+ * .gov API host, so provenance is disclosed on every response (the ProPublica /
12
+ * CourtListener / get.gov republisher idiom — here NIST is the first-party author).
13
+ *
14
+ * PATTERN: the CISA-KEV static-file idiom (nvd.ts) — a fixed-host const URL fetched
15
+ * via getJson (redirect:"error" + 30s timeout), memoized 6h, with a plausibility
16
+ * FLOOR (a truncated catalog must NEVER read as "control not found"), then
17
+ * client-side filter/lookup. An outage/4xx/timeout THROWS (never a fake empty).
18
+ *
19
+ * SSRF: fixed host `raw.githubusercontent.com` + a fixed, pinned path (no free
20
+ * host/path). Filtering is CLIENT-SIDE over the parsed catalog.
21
+ */
22
+
23
+ import { getJson, driftError } from "./datasource.js";
24
+ import { memoize } from "./cache.js";
25
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
26
+
27
+ export const OSCAL_HOST = "raw.githubusercontent.com";
28
+ const OSCAL_URL =
29
+ "https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json";
30
+ const OSCAL_LABEL = "nist-oscal:sp800-53r5";
31
+ const OSCAL_TIMEOUT_MS = 30_000;
32
+ const OSCAL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
33
+ // Plausibility floor: 800-53 Rev5 has 20 control families and ~1000 controls. A
34
+ // truncated catalog with far fewer must THROW, never read as "control not found".
35
+ const FAMILY_FLOOR = 15;
36
+
37
+ const PROVENANCE_NOTE =
38
+ "Source: NIST SP 800-53 Rev 5 OSCAL catalog, published at github.com/usnistgov/oscal-content (NIST's canonical machine-readable release; authoritative first-party data served from GitHub, not a .gov API host).";
39
+ const CLIENT_FILTER_NOTE =
40
+ "The catalog has no query API — the full published OSCAL JSON is fetched (cached 6h) and filtered CLIENT-SIDE (controlId exact, family exact, keyword = case-insensitive substring over title+statement). totalAvailable is the EXACT match count.";
41
+ const REFERENCE_NOTE =
42
+ "This is the REQUIREMENT catalog (control text), NOT an assessment or an authorization. A control's applicability depends on the system's FIPS-199 impact baseline (Low/Moderate/High) and overlay — which this catalog does not encode.";
43
+
44
+ // ─── Parsed shapes ────────────────────────────────────────────────
45
+ export type NistControl = {
46
+ id: string; // display form, e.g. "AC-2"
47
+ family: string; // e.g. "AC — Access Control"
48
+ title: string;
49
+ statement: string; // assembled requirement prose (labelled, multi-line)
50
+ guidance: string | null; // discussion prose
51
+ enhancements: { id: string; title: string }[]; // e.g. AC-2(1)
52
+ };
53
+
54
+ type OscalPart = {
55
+ name?: string;
56
+ prose?: string;
57
+ props?: { name?: string; value?: string }[];
58
+ parts?: OscalPart[];
59
+ };
60
+ type OscalControl = {
61
+ id?: string;
62
+ title?: string;
63
+ parts?: OscalPart[];
64
+ controls?: OscalControl[];
65
+ };
66
+ type OscalGroup = { id?: string; title?: string; controls?: OscalControl[] };
67
+
68
+ /** OSCAL control id ("ac-2", "ac-2.1") → display id ("AC-2", "AC-2(1)"). */
69
+ function displayId(rawId: string): string {
70
+ const m = /^([a-z]+)-(\d+)(?:\.(\d+))?$/i.exec(rawId.trim());
71
+ if (!m) return rawId.toUpperCase();
72
+ const fam = (m[1] ?? "").toUpperCase();
73
+ // Strip leading zeros so a zero-padded input ('AC-02') normalizes to the catalog's
74
+ // canonical unpadded form ('AC-2') — else an exact controlId lookup would miss.
75
+ const num = String(Number(m[2] ?? "0"));
76
+ const enh = m[3] !== undefined ? String(Number(m[3])) : undefined;
77
+ return enh !== undefined ? `${fam}-${num}(${enh})` : `${fam}-${num}`;
78
+ }
79
+
80
+ /** Recursively collect a statement part's prose as labelled, indented lines. */
81
+ function collectProse(part: OscalPart, depth: number, out: string[]): void {
82
+ const label = part.props?.find((p) => p.name === "label")?.value;
83
+ const indent = " ".repeat(depth);
84
+ const prefix = label ? `${label} ` : "";
85
+ if (part.prose && part.prose.trim().length > 0) {
86
+ out.push(`${indent}${prefix}${part.prose.trim()}`);
87
+ } else if (label) {
88
+ out.push(`${indent}${prefix}`.trimEnd());
89
+ }
90
+ for (const sub of part.parts ?? []) collectProse(sub, depth + 1, out);
91
+ }
92
+
93
+ function partProse(control: OscalControl, name: string): string | null {
94
+ const part = (control.parts ?? []).find((p) => p.name === name);
95
+ if (!part) return null;
96
+ const out: string[] = [];
97
+ collectProse(part, 0, out);
98
+ const text = out.join("\n").trim();
99
+ return text.length > 0 ? text : null;
100
+ }
101
+
102
+ function mapControl(control: OscalControl, family: string): NistControl {
103
+ return {
104
+ id: displayId(control.id ?? ""),
105
+ family,
106
+ title: (control.title ?? "").trim(),
107
+ statement: partProse(control, "statement") ?? "",
108
+ guidance: partProse(control, "guidance"),
109
+ enhancements: (control.controls ?? []).map((e) => ({
110
+ id: displayId(e.id ?? ""),
111
+ title: (e.title ?? "").trim(),
112
+ })),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Fetch + parse the OSCAL catalog into a flat list of TOP-LEVEL controls, memoized
118
+ * 6h. Header/shape guarded: `catalog.groups` MUST be an array with ≥ FAMILY_FLOOR
119
+ * families (else driftError — a truncated catalog is NEVER a fake empty). Enhancements
120
+ * are carried on each control (not indexed as top-level entries).
121
+ */
122
+ async function loadControls(): Promise<NistControl[]> {
123
+ return memoize(
124
+ "nist:sp800-53r5",
125
+ async () => {
126
+ const built = new URL(OSCAL_URL);
127
+ if (built.hostname !== OSCAL_HOST || built.protocol !== "https:") {
128
+ throw driftError(OSCAL_LABEL, `Constructed OSCAL URL host ${JSON.stringify(built.hostname)} is not ${OSCAL_HOST} over https — refusing to fetch (SSRF safety).`);
129
+ }
130
+ const body = (await getJson(OSCAL_URL, {
131
+ label: OSCAL_LABEL,
132
+ redirect: "error",
133
+ timeoutMs: OSCAL_TIMEOUT_MS,
134
+ })) as { catalog?: { groups?: unknown } };
135
+ const groups = body.catalog?.groups;
136
+ if (!Array.isArray(groups) || groups.length < FAMILY_FLOOR) {
137
+ throw driftError(
138
+ OSCAL_LABEL,
139
+ `OSCAL catalog.groups missing or implausibly small (${Array.isArray(groups) ? groups.length : "not-an-array"} < ${FAMILY_FLOOR} families) — treating as schema drift / truncation, never a fake-empty catalog.`,
140
+ );
141
+ }
142
+ const controls: NistControl[] = [];
143
+ for (const g of groups as OscalGroup[]) {
144
+ const family = `${(g.id ?? "").toUpperCase()} — ${(g.title ?? "").trim()}`;
145
+ for (const c of g.controls ?? []) controls.push(mapControl(c, family));
146
+ }
147
+ return controls;
148
+ },
149
+ OSCAL_CACHE_TTL_MS,
150
+ );
151
+ }
152
+
153
+ // ─── Tool: nist_800_53_controls ───────────────────────────────────
154
+ /**
155
+ * Look up NIST SP 800-53 Rev 5 controls by controlId (exact, e.g. "AC-2"), family
156
+ * (exact family letter or name, e.g. "AC" / "Access Control"), and/or keyword
157
+ * (case-insensitive substring over title + statement). Client-side filters over the
158
+ * cached OSCAL catalog; honest `_meta` (exact match total; provenance disclosed).
159
+ */
160
+ export async function searchControls(args: {
161
+ controlId?: string;
162
+ family?: string;
163
+ keyword?: string;
164
+ limit?: number;
165
+ offset?: number;
166
+ }): Promise<MetaBundle> {
167
+ const limit = args.limit ?? 25;
168
+ const offset = args.offset ?? 0;
169
+ const all = await loadControls();
170
+
171
+ const filtersApplied: string[] = [];
172
+ const idQ = args.controlId !== undefined ? displayId(args.controlId) : undefined;
173
+ const famQ = args.family?.trim().toLowerCase();
174
+ const kwQ = args.keyword?.trim().toLowerCase();
175
+ if (args.controlId !== undefined) filtersApplied.push("controlId");
176
+ if (args.family !== undefined) filtersApplied.push("family");
177
+ if (args.keyword !== undefined) filtersApplied.push("keyword");
178
+
179
+ const matched = all.filter((c) => {
180
+ if (idQ && c.id.toUpperCase() !== idQ.toUpperCase()) return false;
181
+ if (famQ) {
182
+ // family field is "AC — Access Control"; match either the letter code or a
183
+ // substring of the title (both case-insensitive).
184
+ const fam = c.family.toLowerCase();
185
+ const code = (fam.split("—")[0] ?? "").trim();
186
+ if (code !== famQ && !fam.includes(famQ)) return false;
187
+ }
188
+ if (kwQ) {
189
+ // Search the title + requirement statement AND each enhancement's title, so a
190
+ // term that lives only in an enhancement (e.g. "multi-factor" → IA-2(1)) still
191
+ // surfaces the parent control. filtersApplied still lists 'keyword'.
192
+ const hay = `${c.title}\n${c.statement}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
193
+ if (!hay.includes(kwQ)) return false;
194
+ }
195
+ return true;
196
+ });
197
+
198
+ const totalAvailable = matched.length;
199
+ const page = matched.slice(offset, offset + limit);
200
+ const returned = page.length;
201
+ const hasMore = offset + returned < totalAvailable;
202
+ const nextOffset = hasMore ? offset + returned : null;
203
+
204
+ return withMeta(
205
+ { controls: page },
206
+ {
207
+ source: "NIST SP 800-53 Rev 5 (OSCAL catalog, keyless)",
208
+ keylessMode: true,
209
+ returned,
210
+ totalAvailable,
211
+ truncated: hasMore,
212
+ filtersApplied,
213
+ filtersDropped: [],
214
+ fieldsUnavailable: [],
215
+ pagination: { offset, limit, hasMore, nextOffset },
216
+ notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, REFERENCE_NOTE],
217
+ } satisfies Partial<ResponseMeta>,
218
+ );
219
+ }
@@ -0,0 +1,460 @@
1
+ /**
2
+ * nonprofit.ts — US TAX-EXEMPT NONPROFITS (IRS Form 990) — the nonprofit /
3
+ * grantee / subcontractor vetting lane (ADR-0060). Who a tax-exempt organization
4
+ * IS (EIN, NTEE code, subsection, ruling date, status) and what its Form 990
5
+ * FINANCIALS look like (revenue, expenses, assets, liabilities by tax year) —
6
+ * the 501(c) signal no contract/spending/grant/lobbying source carries.
7
+ *
8
+ * ★ PROVENANCE — THIS IS NOT A .gov API (must be disclosed). The DATA is IRS Form
9
+ * 990 filings — FEDERAL tax-exempt PUBLIC RECORDS — but the API is **ProPublica
10
+ * Nonprofit Explorer**, operated by **ProPublica** (a non-profit newsroom) which
11
+ * republishes those records KEYLESS. The IRS itself offers NO clean query API
12
+ * (only bulk downloads / a web UI). So every response's `_meta.source` AND a note
13
+ * name "IRS Form 990 data via ProPublica Nonprofit Explorer" — the tool NEVER
14
+ * presents itself as a government API.
15
+ *
16
+ * ★ KEYLESS — no key of any kind. Anonymous GETs return HTTP 200. There is NO
17
+ * KEY_REGISTRY / keys.ts / API_KEYS.md entry for this source.
18
+ *
19
+ * The module writes ZERO fetch/coercion/error/meta code of its own: it REUSES
20
+ * `getJson` (the shared fetch envelope, redirect:"error") / `driftError` /
21
+ * `num`·`str` (coerce.ts, null-never-0/empty) / `withMeta`·`buildMeta`.
22
+ *
23
+ * SEARCH GET https://projects.propublica.org/nonprofits/api/v2/search.json
24
+ * ?q=&state[id]=&ntee[id]=&page=
25
+ * → { total_results, organizations:[{ ein, name, sub_name, city, state,
26
+ * ntee_code, subseccd, score }], num_pages, cur_page, per_page,
27
+ * page_offset }
28
+ * DETAIL GET https://projects.propublica.org/nonprofits/api/v2/organizations/{ein}.json
29
+ * → { organization:{ ein, name, address, city, state, zipcode, ntee_code,
30
+ * subsection_code, ruling_date, exempt_organization_status_code,
31
+ * foundation_code }, filings_with_data:[{ tax_prd_yr, formtype, pdf_url,
32
+ * totrevenue, totfuncexpns, totassetsend, totliabend }] }
33
+ *
34
+ * ★ HONESTY (ADR-0060 P1–P5):
35
+ * [P1] SEARCH totalAvailable = `total_results` (the API's REAL total for the
36
+ * query) — NEVER organizations.length. Page pagination (page is 0-based):
37
+ * hasMore = (cur_page+1) < num_pages; the next page number is surfaced in
38
+ * a note. DETAIL totalAvailable = filings.length (the COMPLETE filing set
39
+ * from the one detail doc — no pagination). Reverting the search total to
40
+ * organizations.length must go RED.
41
+ * [P2] SEARCH a genuine no-match (organizations:[]) ⇒ honest empty (returned:0,
42
+ * complete:true). DETAIL an unknown EIN (HTTP 404) ⇒ not_found (NEVER a
43
+ * fabricated empty org). A 4xx ⇒ invalid_input; a 5xx/timeout ⇒
44
+ * upstream_unavailable THROW; a 200 non-JSON ⇒ schema_drift.
45
+ * [P3] The four Form 990 figures (totrevenue/totfuncexpns/totassetsend/
46
+ * totliabend) ride `num()` — a genuine 0 STAYS 0, an absent figure ⇒ null
47
+ * (NEVER 0-faked). EIN + the codes are strings; ruling_date is a string.
48
+ * [P4] SEARCH `organizations` non-array OR `total_results` non-number ⇒
49
+ * driftError. DETAIL `organization` non-object OR `filings_with_data`
50
+ * non-array ⇒ driftError (never a fabricated empty/total).
51
+ * [SSRF] fixed host `projects.propublica.org`; a post-construction hostname/
52
+ * protocol assert + `redirect:"error"`; the query VALUES ride
53
+ * URLSearchParams (incl. the `state[id]`/`ntee[id]` bracket keys);
54
+ * `ein` charclass `^\d{1,9}$` (path segment); `state` `^[A-Za-z]{2}$`;
55
+ * `ntee` an integer 1..10.
56
+ */
57
+
58
+ import { ToolErrorCarrier } from "./errors.js";
59
+ import { getJson, driftError } from "./datasource.js";
60
+ import { num, str } from "./coerce.js";
61
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
62
+
63
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
64
+ // ADR-0005 v2 FIX-C) so a `num` regression fails together across sources.
65
+ export { num };
66
+
67
+ // ─── SSRF core: the single fixed host + base path ─────────────────
68
+ export const NONPROFIT_HOST = "projects.propublica.org";
69
+ const NONPROFIT_BASE = "/nonprofits/api/v2";
70
+ // HOST+path labels — surface in ToolError.upstreamEndpoint. No token exists for
71
+ // this keyless source, so no secret can ever appear here.
72
+ const NONPROFIT_SEARCH_LABEL = "propublica-nonprofit:/nonprofits/api/v2/search";
73
+ const NONPROFIT_ORG_LABEL = "propublica-nonprofit:/nonprofits/api/v2/organizations";
74
+
75
+ // ─── Validation (SSRF + "verify the input" honesty) ───────────────
76
+ const STATE_RE = /^[A-Za-z]{2}$/; // a 2-letter US state/territory code
77
+ const EIN_RE = /^\d{1,9}$/; // a numeric EIN (1..9 digits), rides the PATH
78
+ // ★ProPublica's not-found SENTINEL (live-verified, NOT in the ADR): an EIN with no
79
+ // matching IRS record does NOT always 404 — an in-range unknown EIN (e.g. 999999999)
80
+ // returns HTTP 200 carrying a SYNTHETIC placeholder org `{ name:"Unknown Organization",
81
+ // …all-null }` with ZERO filings_with_data. Surfacing that verbatim would present a
82
+ // FABRICATED empty org as a real hit (a P2 honesty violation). We detect the exact
83
+ // sentinel name + empty structured filings and map it to not_found, EXACTLY like a 404.
84
+ const PROPUBLICA_NOT_FOUND_NAME = "Unknown Organization";
85
+ const NTEE_MIN = 1;
86
+ const NTEE_MAX = 10; // the NTEE major-category filter, 1..10
87
+ const DEFAULT_PAGE = 0; // the API's page is 0-BASED
88
+ const FALLBACK_PER_PAGE = 25; // the API's fixed page size (~25); a defensive fallback
89
+
90
+ // ─── Honesty notes (ADR-0060 required set) ────────────────────────
91
+ const PROVENANCE_NOTE =
92
+ "Data = IRS Form 990 filings (federal tax-exempt public records), served by ProPublica Nonprofit Explorer (ProPublica, a non-profit newsroom, which republishes them keyless) — NOT a .gov API. The IRS itself has no clean query API (only bulk downloads / a web UI). Treat figures as of ProPublica's last IRS ingest.";
93
+ const SEARCH_TOTAL_NOTE =
94
+ "totalAvailable is the API's real total_results — the total match count for the query (NOT the organizations on this page). Pagination is page-based and 0-INDEXED (pass page=cur_page+1 for the next page while hasMore).";
95
+ const FINANCIALS_TOTAL_NOTE =
96
+ "totalAvailable is filings.length — the COMPLETE set of Form 990 filings-with-data carried by this organization's detail document (there is no pagination; this is the whole set, not a page).";
97
+ const FINANCIALS_MONEY_NOTE =
98
+ "revenueUsd / expensesUsd / assetsUsd / liabilitiesUsd are parsed from the Form 990 totrevenue / totfuncexpns / totassetsend / totliabend. A genuine reported 0 is preserved as 0; an absent figure maps to null — NEVER 0.";
99
+
100
+ // ─── Curated search shape ─────────────────────────────────────────
101
+ export type NonprofitOrgSummary = {
102
+ ein: string | null;
103
+ name: string | null;
104
+ city: string | null;
105
+ state: string | null;
106
+ nteeCode: string | null; // ntee_code (the NTEE classification, e.g. "E21")
107
+ subsectionCode: string | null; // subseccd (the 501(c) subsection code)
108
+ };
109
+
110
+ /** Map ONE search `organizations[]` row → the curated summary shape. */
111
+ function mapOrgSummary(raw: unknown): NonprofitOrgSummary {
112
+ const o = (raw ?? {}) as Record<string, unknown>;
113
+ return {
114
+ // EIN + codes are IDENTIFIERS ⇒ strings (never num-coerced).
115
+ ein: str(o.ein),
116
+ name: str(o.name),
117
+ city: str(o.city),
118
+ state: str(o.state),
119
+ nteeCode: str(o.ntee_code),
120
+ subsectionCode: str(o.subseccd),
121
+ };
122
+ }
123
+
124
+ // ─── Curated financials shapes ────────────────────────────────────
125
+ export type NonprofitOrganization = {
126
+ ein: string | null;
127
+ name: string | null;
128
+ address: string | null;
129
+ city: string | null;
130
+ state: string | null;
131
+ zip: string | null; // zipcode
132
+ nteeCode: string | null; // ntee_code
133
+ subsectionCode: string | null; // subsection_code
134
+ rulingDate: string | null; // ruling_date (a date STRING — never coerced)
135
+ statusCode: string | null; // exempt_organization_status_code
136
+ };
137
+
138
+ export type NonprofitFiling = {
139
+ taxYear: number | null; // tax_prd_yr (a filing year)
140
+ formType: string | null; // formtype
141
+ revenueUsd: number | null; // totrevenue — null-never-0
142
+ expensesUsd: number | null; // totfuncexpns — null-never-0
143
+ assetsUsd: number | null; // totassetsend — null-never-0
144
+ liabilitiesUsd: number | null; // totliabend — null-never-0
145
+ pdfUrl: string | null; // pdf_url (the scanned Form 990 PDF)
146
+ };
147
+
148
+ /** Map the detail `organization` object → the curated organization shape. */
149
+ function mapOrganization(raw: unknown): NonprofitOrganization {
150
+ const o = (raw ?? {}) as Record<string, unknown>;
151
+ return {
152
+ ein: str(o.ein),
153
+ name: str(o.name),
154
+ address: str(o.address),
155
+ city: str(o.city),
156
+ state: str(o.state),
157
+ zip: str(o.zipcode),
158
+ nteeCode: str(o.ntee_code),
159
+ subsectionCode: str(o.subsection_code),
160
+ rulingDate: str(o.ruling_date),
161
+ statusCode: str(o.exempt_organization_status_code),
162
+ };
163
+ }
164
+
165
+ /** Map ONE `filings_with_data[]` row → the curated filing shape (money via num). */
166
+ function mapFiling(raw: unknown): NonprofitFiling {
167
+ const f = (raw ?? {}) as Record<string, unknown>;
168
+ return {
169
+ taxYear: num(f.tax_prd_yr),
170
+ formType: str(f.formtype),
171
+ // [P3] a genuine 0 STAYS 0; absent ⇒ null (NEVER 0-faked).
172
+ revenueUsd: num(f.totrevenue),
173
+ expensesUsd: num(f.totfuncexpns),
174
+ assetsUsd: num(f.totassetsend),
175
+ liabilitiesUsd: num(f.totliabend),
176
+ pdfUrl: str(f.pdf_url),
177
+ };
178
+ }
179
+
180
+ // ─── Tool: nonprofit_search ───────────────────────────────────────
181
+ export type NonprofitSearchArgs = {
182
+ query?: string; // → q
183
+ state?: string; // 2-letter → state[id]
184
+ ntee?: number; // 1..10 → ntee[id]
185
+ page?: number; // ≥0, default 0 (the API's page is 0-based)
186
+ };
187
+
188
+ /**
189
+ * Search US tax-exempt nonprofits (IRS Form 990) via ProPublica Nonprofit Explorer
190
+ * (`/nonprofits/api/v2/search.json`) → curated org summaries + honest `_meta`.
191
+ * KEYLESS. ★PROVENANCE: this is ProPublica (a non-profit newsroom) republishing
192
+ * IRS Form 990 public records — NOT a .gov API. ★totalAvailable is the API's REAL
193
+ * `total_results` — never organizations.length; page-based (0-indexed) pagination.
194
+ */
195
+ export async function search(args: NonprofitSearchArgs): Promise<MetaBundle> {
196
+ const label = NONPROFIT_SEARCH_LABEL;
197
+
198
+ // ── Validate + default (belt-and-suspenders behind the server Zod; a DIRECT
199
+ // handler call bypasses Zod). state/ntee/page are charclass/range-guarded;
200
+ // the free-text query rides URLSearchParams (encoded). ──
201
+ if (args.state !== undefined && !STATE_RE.test(args.state)) {
202
+ throw new ToolErrorCarrier({
203
+ kind: "invalid_input",
204
+ retryable: false,
205
+ message: `Invalid state ${JSON.stringify(args.state)} — expected a 2-letter US state/territory code (^[A-Za-z]{2}$), e.g. "VA".`,
206
+ upstreamEndpoint: label,
207
+ });
208
+ }
209
+ if (
210
+ args.ntee !== undefined &&
211
+ (!Number.isInteger(args.ntee) || args.ntee < NTEE_MIN || args.ntee > NTEE_MAX)
212
+ ) {
213
+ throw new ToolErrorCarrier({
214
+ kind: "invalid_input",
215
+ retryable: false,
216
+ message: `Invalid ntee ${JSON.stringify(args.ntee)} — expected an integer NTEE major category 1..10.`,
217
+ upstreamEndpoint: label,
218
+ });
219
+ }
220
+ const page = clampPage(args.page);
221
+
222
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
223
+ // passthrough; every VALUE is URLSearchParams-encoded, incl. the bracket keys
224
+ // `state[id]`/`ntee[id]`). ──
225
+ const params = new URLSearchParams();
226
+ const filtersApplied: string[] = [];
227
+ if (args.query !== undefined && args.query !== "") {
228
+ params.set("q", args.query);
229
+ filtersApplied.push("query");
230
+ }
231
+ if (args.state !== undefined) {
232
+ params.set("state[id]", args.state.toUpperCase());
233
+ filtersApplied.push("state");
234
+ }
235
+ if (args.ntee !== undefined) {
236
+ params.set("ntee[id]", String(args.ntee));
237
+ filtersApplied.push("ntee");
238
+ }
239
+ params.set("page", String(page));
240
+
241
+ const url = `https://${NONPROFIT_HOST}${NONPROFIT_BASE}/search.json?${params.toString()}`;
242
+ assertOnHost(url, label);
243
+
244
+ // ── Fetch through the shared envelope. redirect:"error" fails closed on any
245
+ // off-host 3xx. A 4xx ⇒ invalid_input; a 5xx/timeout ⇒ upstream_unavailable
246
+ // THROW; a 429 ⇒ rate_limited THROW; a 200 non-JSON ⇒ getJson's r.json()
247
+ // throws a SyntaxError ⇒ schema_drift. ──
248
+ let body: unknown;
249
+ try {
250
+ body = await getJson<unknown>(url, { label, redirect: "error" });
251
+ } catch (e) {
252
+ if (e instanceof SyntaxError) {
253
+ throw driftError(
254
+ label,
255
+ "ProPublica Nonprofit search returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
256
+ );
257
+ }
258
+ throw e; // 5xx → upstream_unavailable, 4xx → invalid_input, 429 → rate_limited …
259
+ }
260
+
261
+ // ── [P4] `organizations` MUST be an array and `total_results` MUST be a number
262
+ // (a missing/wrong-typed either is drift, never a fabricated empty/total). ──
263
+ const b = (body ?? {}) as {
264
+ organizations?: unknown;
265
+ total_results?: unknown;
266
+ num_pages?: unknown;
267
+ cur_page?: unknown;
268
+ per_page?: unknown;
269
+ page_offset?: unknown;
270
+ };
271
+ if (!Array.isArray(b.organizations)) {
272
+ throw driftError(
273
+ label,
274
+ "ProPublica Nonprofit search shape drift — `organizations` must be an array.",
275
+ );
276
+ }
277
+ if (typeof b.total_results !== "number" || !Number.isFinite(b.total_results)) {
278
+ throw driftError(
279
+ label,
280
+ "ProPublica Nonprofit search shape drift — `total_results` (the total match count) must be a number.",
281
+ );
282
+ }
283
+
284
+ const organizations = (b.organizations as unknown[]).map(mapOrgSummary);
285
+ const returned = organizations.length;
286
+
287
+ // ── [P1] totalAvailable is the API's REAL total_results, NEVER organizations.length.
288
+ // Page-based + 0-INDEXED: hasMore = (cur_page+1) < num_pages; surface the next
289
+ // page. cur_page/num_pages/per_page/page_offset via num() (defensive fallbacks). ──
290
+ const totalAvailable = b.total_results;
291
+ const curPage = num(b.cur_page) ?? page;
292
+ const numPages = num(b.num_pages);
293
+ const perPage = num(b.per_page) ?? (returned > 0 ? returned : FALLBACK_PER_PAGE);
294
+ const hasMore = numPages !== null ? curPage + 1 < numPages : false;
295
+ const offset = num(b.page_offset) ?? curPage * perPage;
296
+ const nextOffset = hasMore ? (curPage + 1) * perPage : null;
297
+
298
+ const notes: string[] = [PROVENANCE_NOTE, SEARCH_TOTAL_NOTE];
299
+ if (hasMore && numPages !== null) {
300
+ notes.push(
301
+ `This is page ${curPage} (0-indexed) of ${numPages} — pass page=${curPage + 1} for the next page.`,
302
+ );
303
+ }
304
+
305
+ return withMeta(
306
+ { organizations },
307
+ {
308
+ source: `${NONPROFIT_HOST} /nonprofits/api/v2/search (IRS Form 990 data via ProPublica Nonprofit Explorer — not a .gov API; keyless)`,
309
+ keylessMode: true,
310
+ returned,
311
+ totalAvailable,
312
+ filtersApplied,
313
+ filtersDropped: [],
314
+ fieldsUnavailable: [],
315
+ pagination: { offset, limit: perPage, hasMore, nextOffset },
316
+ notes,
317
+ } satisfies Partial<ResponseMeta>,
318
+ );
319
+ }
320
+
321
+ // ─── Tool: nonprofit_financials ───────────────────────────────────
322
+ export type NonprofitFinancialsArgs = {
323
+ ein: string; // required; ^\d{1,9}$ (rides the PATH)
324
+ };
325
+
326
+ /**
327
+ * Fetch ONE nonprofit's IRS Form 990 profile + financials via ProPublica Nonprofit
328
+ * Explorer (`/nonprofits/api/v2/organizations/{ein}.json`) → curated organization +
329
+ * filings + honest `_meta`. KEYLESS. ★PROVENANCE: ProPublica (a non-profit newsroom)
330
+ * republishing IRS Form 990 public records — NOT a .gov API. An unknown EIN (HTTP
331
+ * 404) ⇒ not_found (never a fabricated empty org). The four Form 990 figures ride
332
+ * num() (null-never-0). totalAvailable = filings.length (the COMPLETE set).
333
+ */
334
+ export async function financials(
335
+ args: NonprofitFinancialsArgs,
336
+ ): Promise<MetaBundle> {
337
+ const label = NONPROFIT_ORG_LABEL;
338
+
339
+ // ── Validate (belt-and-suspenders behind the server Zod). ein is charclass-
340
+ // guarded PRE-fetch — it rides the URL PATH, so it MUST be digits-only. ──
341
+ if (typeof args.ein !== "string" || !EIN_RE.test(args.ein)) {
342
+ throw new ToolErrorCarrier({
343
+ kind: "invalid_input",
344
+ retryable: false,
345
+ message: `Invalid ein ${JSON.stringify(args.ein)} — expected a numeric EIN of 1..9 digits (^\\d{1,9}$), e.g. "530196605".`,
346
+ upstreamEndpoint: label,
347
+ });
348
+ }
349
+
350
+ // ein is digits-only (EIN_RE) ⇒ safe as a path segment; no separators can steer
351
+ // the authority. Build + re-assert the host (SSRF belt-and-suspenders).
352
+ const url = `https://${NONPROFIT_HOST}${NONPROFIT_BASE}/organizations/${args.ein}.json`;
353
+ assertOnHost(url, label);
354
+
355
+ // ── Fetch through the shared envelope. A 404 (unknown EIN) ⇒ not_found (the
356
+ // shared taxonomy — never a fabricated empty org); a 4xx ⇒ invalid_input; a
357
+ // 5xx/timeout ⇒ upstream_unavailable THROW; a 200 non-JSON ⇒ schema_drift. ──
358
+ let body: unknown;
359
+ try {
360
+ body = await getJson<unknown>(url, { label, redirect: "error" });
361
+ } catch (e) {
362
+ if (e instanceof SyntaxError) {
363
+ throw driftError(
364
+ label,
365
+ "ProPublica Nonprofit organization detail returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
366
+ );
367
+ }
368
+ throw e; // 404 → not_found, 5xx → upstream_unavailable, 4xx → invalid_input …
369
+ }
370
+
371
+ // ── [P4] `organization` MUST be an object and `filings_with_data` MUST be an
372
+ // array (a missing/wrong-typed either is drift, never a fabricated empty). ──
373
+ const b = (body ?? {}) as {
374
+ organization?: unknown;
375
+ filings_with_data?: unknown;
376
+ };
377
+ if (
378
+ b.organization === null ||
379
+ typeof b.organization !== "object" ||
380
+ Array.isArray(b.organization)
381
+ ) {
382
+ throw driftError(
383
+ label,
384
+ "ProPublica Nonprofit organization detail shape drift — `organization` must be an object.",
385
+ );
386
+ }
387
+ if (!Array.isArray(b.filings_with_data)) {
388
+ throw driftError(
389
+ label,
390
+ "ProPublica Nonprofit organization detail shape drift — `filings_with_data` must be an array.",
391
+ );
392
+ }
393
+
394
+ const organization = mapOrganization(b.organization);
395
+ const filings = (b.filings_with_data as unknown[]).map(mapFiling);
396
+ const returned = filings.length;
397
+
398
+ // ── [P2] ★not-found SENTINEL: ProPublica returns HTTP 200 + a synthetic
399
+ // `{ name:"Unknown Organization", …all-null }` placeholder (zero
400
+ // filings_with_data) for an in-range EIN with no IRS record. That is a
401
+ // FABRICATED empty org — surface it as not_found (identical to a 404), NEVER
402
+ // as a real hit. Gated on BOTH the exact sentinel name AND empty structured
403
+ // filings, so a real org (which would carry its true name / filings) is safe. ──
404
+ if (organization.name === PROPUBLICA_NOT_FOUND_NAME && returned === 0) {
405
+ throw new ToolErrorCarrier({
406
+ kind: "not_found",
407
+ retryable: false,
408
+ message: `No IRS Form 990 record for EIN ${args.ein} — ProPublica returned its "${PROPUBLICA_NOT_FOUND_NAME}" placeholder (no matching tax-exempt organization). Verify the EIN.`,
409
+ upstreamEndpoint: label,
410
+ });
411
+ }
412
+
413
+ // ── [P1] totalAvailable = filings.length — the COMPLETE filing set from the one
414
+ // detail document (no pagination). ──
415
+ const notes: string[] = [
416
+ PROVENANCE_NOTE,
417
+ FINANCIALS_TOTAL_NOTE,
418
+ FINANCIALS_MONEY_NOTE,
419
+ ];
420
+
421
+ return withMeta(
422
+ { organization, filings },
423
+ {
424
+ source: `${NONPROFIT_HOST} /nonprofits/api/v2/organizations (IRS Form 990 data via ProPublica Nonprofit Explorer — not a .gov API; keyless)`,
425
+ keylessMode: true,
426
+ returned,
427
+ totalAvailable: returned,
428
+ filtersApplied: [],
429
+ filtersDropped: [],
430
+ fieldsUnavailable: [],
431
+ // The complete set is in one document — no pagination, hasMore:false.
432
+ pagination: { offset: 0, limit: returned, hasMore: false, nextOffset: null },
433
+ notes,
434
+ } satisfies Partial<ResponseMeta>,
435
+ );
436
+ }
437
+
438
+ // ─── SSRF host assert (shared by both tools) ──────────────────────
439
+ /**
440
+ * Belt-and-suspenders: the fixed host + strictly-built URL leave nothing to steer
441
+ * the authority; assert the built URL cannot have been moved off-host / downgraded.
442
+ */
443
+ function assertOnHost(url: string, label: string): void {
444
+ const built = new URL(url);
445
+ if (built.hostname !== NONPROFIT_HOST || built.protocol !== "https:") {
446
+ throw new ToolErrorCarrier({
447
+ kind: "invalid_input",
448
+ retryable: false,
449
+ message: `Constructed ProPublica Nonprofit URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${NONPROFIT_HOST} over https — refusing to fetch (SSRF safety).`,
450
+ upstreamEndpoint: label,
451
+ });
452
+ }
453
+ }
454
+
455
+ // ─── Small clamp (defensive, behind the server Zod bounds) ─────────
456
+ function clampPage(v: unknown): number {
457
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_PAGE;
458
+ const n = Math.floor(v);
459
+ return n < 0 ? 0 : n;
460
+ }