@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
package/src/nonprofit.ts
ADDED
|
@@ -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
|
+
}
|