@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,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
+ }