@cliwant/mcp-sam-gov 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +22 -9
- package/README.ko.md +22 -9
- package/README.md +70 -12
- package/dist/bea.d.ts +105 -0
- package/dist/bea.d.ts.map +1 -0
- package/dist/bea.js +303 -0
- package/dist/bea.js.map +1 -0
- package/dist/census-economic.d.ts +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +12 -6
- package/dist/census-economic.js.map +1 -1
- package/dist/cms-facility.d.ts +112 -0
- package/dist/cms-facility.d.ts.map +1 -0
- package/dist/cms-facility.js +311 -0
- package/dist/cms-facility.js.map +1 -0
- package/dist/cms-hospital.d.ts +105 -0
- package/dist/cms-hospital.d.ts.map +1 -0
- package/dist/cms-hospital.js +290 -0
- package/dist/cms-hospital.js.map +1 -0
- package/dist/cms-supplier.d.ts +133 -0
- package/dist/cms-supplier.d.ts.map +1 -0
- package/dist/cms-supplier.js +414 -0
- package/dist/cms-supplier.js.map +1 -0
- package/dist/cms-utilization.d.ts +113 -0
- package/dist/cms-utilization.d.ts.map +1 -0
- package/dist/cms-utilization.js +328 -0
- package/dist/cms-utilization.js.map +1 -0
- package/dist/courtlistener.d.ts +115 -0
- package/dist/courtlistener.d.ts.map +1 -0
- package/dist/courtlistener.js +398 -0
- package/dist/courtlistener.js.map +1 -0
- package/dist/cpsc.d.ts +81 -0
- package/dist/cpsc.d.ts.map +1 -0
- package/dist/cpsc.js +283 -0
- package/dist/cpsc.js.map +1 -0
- package/dist/dol.d.ts +118 -0
- package/dist/dol.d.ts.map +1 -0
- package/dist/dol.js +421 -0
- package/dist/dol.js.map +1 -0
- package/dist/epa-envirofacts.d.ts +97 -0
- package/dist/epa-envirofacts.d.ts.map +1 -0
- package/dist/epa-envirofacts.js +292 -0
- package/dist/epa-envirofacts.js.map +1 -0
- package/dist/fred.d.ts +1 -1
- package/dist/fred.js +1 -1
- package/dist/keys.d.ts +11 -8
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +55 -8
- package/dist/keys.js.map +1 -1
- package/dist/lda.d.ts +105 -0
- package/dist/lda.d.ts.map +1 -0
- package/dist/lda.js +317 -0
- package/dist/lda.js.map +1 -0
- package/dist/nhtsa.d.ts +91 -0
- package/dist/nhtsa.d.ts.map +1 -0
- package/dist/nhtsa.js +263 -0
- package/dist/nhtsa.js.map +1 -0
- package/dist/nonprofit.d.ts +116 -0
- package/dist/nonprofit.d.ts.map +1 -0
- package/dist/nonprofit.js +342 -0
- package/dist/nonprofit.js.map +1 -0
- package/dist/openfda-device.d.ts +85 -0
- package/dist/openfda-device.d.ts.map +1 -0
- package/dist/openfda-device.js +277 -0
- package/dist/openfda-device.js.map +1 -0
- package/dist/openfda.d.ts +133 -0
- package/dist/openfda.d.ts.map +1 -0
- package/dist/openfda.js +402 -0
- package/dist/openfda.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +872 -6
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/src/bea.ts +372 -0
- package/src/census-economic.ts +12 -6
- package/src/cms-facility.ts +379 -0
- package/src/cms-hospital.ts +344 -0
- package/src/cms-supplier.ts +527 -0
- package/src/cms-utilization.ts +389 -0
- package/src/courtlistener.ts +465 -0
- package/src/cpsc.ts +333 -0
- package/src/dol.ts +515 -0
- package/src/epa-envirofacts.ts +342 -0
- package/src/fred.ts +1 -1
- package/src/keys.ts +60 -8
- package/src/lda.ts +385 -0
- package/src/nhtsa.ts +352 -0
- package/src/nonprofit.ts +460 -0
- package/src/openfda-device.ts +356 -0
- package/src/openfda.ts +495 -0
- package/src/server.ts +995 -6
|
@@ -0,0 +1,342 @@
|
|
|
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
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
58
|
+
import { getJson, driftError } from "./datasource.js";
|
|
59
|
+
import { num, str } from "./coerce.js";
|
|
60
|
+
import { withMeta } from "./meta.js";
|
|
61
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
62
|
+
// ADR-0005 v2 FIX-C) so a `num` regression fails together across sources.
|
|
63
|
+
export { num };
|
|
64
|
+
// ─── SSRF core: the single fixed host + base path ─────────────────
|
|
65
|
+
export const NONPROFIT_HOST = "projects.propublica.org";
|
|
66
|
+
const NONPROFIT_BASE = "/nonprofits/api/v2";
|
|
67
|
+
// HOST+path labels — surface in ToolError.upstreamEndpoint. No token exists for
|
|
68
|
+
// this keyless source, so no secret can ever appear here.
|
|
69
|
+
const NONPROFIT_SEARCH_LABEL = "propublica-nonprofit:/nonprofits/api/v2/search";
|
|
70
|
+
const NONPROFIT_ORG_LABEL = "propublica-nonprofit:/nonprofits/api/v2/organizations";
|
|
71
|
+
// ─── Validation (SSRF + "verify the input" honesty) ───────────────
|
|
72
|
+
const STATE_RE = /^[A-Za-z]{2}$/; // a 2-letter US state/territory code
|
|
73
|
+
const EIN_RE = /^\d{1,9}$/; // a numeric EIN (1..9 digits), rides the PATH
|
|
74
|
+
// ★ProPublica's not-found SENTINEL (live-verified, NOT in the ADR): an EIN with no
|
|
75
|
+
// matching IRS record does NOT always 404 — an in-range unknown EIN (e.g. 999999999)
|
|
76
|
+
// returns HTTP 200 carrying a SYNTHETIC placeholder org `{ name:"Unknown Organization",
|
|
77
|
+
// …all-null }` with ZERO filings_with_data. Surfacing that verbatim would present a
|
|
78
|
+
// FABRICATED empty org as a real hit (a P2 honesty violation). We detect the exact
|
|
79
|
+
// sentinel name + empty structured filings and map it to not_found, EXACTLY like a 404.
|
|
80
|
+
const PROPUBLICA_NOT_FOUND_NAME = "Unknown Organization";
|
|
81
|
+
const NTEE_MIN = 1;
|
|
82
|
+
const NTEE_MAX = 10; // the NTEE major-category filter, 1..10
|
|
83
|
+
const DEFAULT_PAGE = 0; // the API's page is 0-BASED
|
|
84
|
+
const FALLBACK_PER_PAGE = 25; // the API's fixed page size (~25); a defensive fallback
|
|
85
|
+
// ─── Honesty notes (ADR-0060 required set) ────────────────────────
|
|
86
|
+
const PROVENANCE_NOTE = "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.";
|
|
87
|
+
const SEARCH_TOTAL_NOTE = "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).";
|
|
88
|
+
const FINANCIALS_TOTAL_NOTE = "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).";
|
|
89
|
+
const FINANCIALS_MONEY_NOTE = "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.";
|
|
90
|
+
/** Map ONE search `organizations[]` row → the curated summary shape. */
|
|
91
|
+
function mapOrgSummary(raw) {
|
|
92
|
+
const o = (raw ?? {});
|
|
93
|
+
return {
|
|
94
|
+
// EIN + codes are IDENTIFIERS ⇒ strings (never num-coerced).
|
|
95
|
+
ein: str(o.ein),
|
|
96
|
+
name: str(o.name),
|
|
97
|
+
city: str(o.city),
|
|
98
|
+
state: str(o.state),
|
|
99
|
+
nteeCode: str(o.ntee_code),
|
|
100
|
+
subsectionCode: str(o.subseccd),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Map the detail `organization` object → the curated organization shape. */
|
|
104
|
+
function mapOrganization(raw) {
|
|
105
|
+
const o = (raw ?? {});
|
|
106
|
+
return {
|
|
107
|
+
ein: str(o.ein),
|
|
108
|
+
name: str(o.name),
|
|
109
|
+
address: str(o.address),
|
|
110
|
+
city: str(o.city),
|
|
111
|
+
state: str(o.state),
|
|
112
|
+
zip: str(o.zipcode),
|
|
113
|
+
nteeCode: str(o.ntee_code),
|
|
114
|
+
subsectionCode: str(o.subsection_code),
|
|
115
|
+
rulingDate: str(o.ruling_date),
|
|
116
|
+
statusCode: str(o.exempt_organization_status_code),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Map ONE `filings_with_data[]` row → the curated filing shape (money via num). */
|
|
120
|
+
function mapFiling(raw) {
|
|
121
|
+
const f = (raw ?? {});
|
|
122
|
+
return {
|
|
123
|
+
taxYear: num(f.tax_prd_yr),
|
|
124
|
+
formType: str(f.formtype),
|
|
125
|
+
// [P3] a genuine 0 STAYS 0; absent ⇒ null (NEVER 0-faked).
|
|
126
|
+
revenueUsd: num(f.totrevenue),
|
|
127
|
+
expensesUsd: num(f.totfuncexpns),
|
|
128
|
+
assetsUsd: num(f.totassetsend),
|
|
129
|
+
liabilitiesUsd: num(f.totliabend),
|
|
130
|
+
pdfUrl: str(f.pdf_url),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Search US tax-exempt nonprofits (IRS Form 990) via ProPublica Nonprofit Explorer
|
|
135
|
+
* (`/nonprofits/api/v2/search.json`) → curated org summaries + honest `_meta`.
|
|
136
|
+
* KEYLESS. ★PROVENANCE: this is ProPublica (a non-profit newsroom) republishing
|
|
137
|
+
* IRS Form 990 public records — NOT a .gov API. ★totalAvailable is the API's REAL
|
|
138
|
+
* `total_results` — never organizations.length; page-based (0-indexed) pagination.
|
|
139
|
+
*/
|
|
140
|
+
export async function search(args) {
|
|
141
|
+
const label = NONPROFIT_SEARCH_LABEL;
|
|
142
|
+
// ── Validate + default (belt-and-suspenders behind the server Zod; a DIRECT
|
|
143
|
+
// handler call bypasses Zod). state/ntee/page are charclass/range-guarded;
|
|
144
|
+
// the free-text query rides URLSearchParams (encoded). ──
|
|
145
|
+
if (args.state !== undefined && !STATE_RE.test(args.state)) {
|
|
146
|
+
throw new ToolErrorCarrier({
|
|
147
|
+
kind: "invalid_input",
|
|
148
|
+
retryable: false,
|
|
149
|
+
message: `Invalid state ${JSON.stringify(args.state)} — expected a 2-letter US state/territory code (^[A-Za-z]{2}$), e.g. "VA".`,
|
|
150
|
+
upstreamEndpoint: label,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (args.ntee !== undefined &&
|
|
154
|
+
(!Number.isInteger(args.ntee) || args.ntee < NTEE_MIN || args.ntee > NTEE_MAX)) {
|
|
155
|
+
throw new ToolErrorCarrier({
|
|
156
|
+
kind: "invalid_input",
|
|
157
|
+
retryable: false,
|
|
158
|
+
message: `Invalid ntee ${JSON.stringify(args.ntee)} — expected an integer NTEE major category 1..10.`,
|
|
159
|
+
upstreamEndpoint: label,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const page = clampPage(args.page);
|
|
163
|
+
// ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
|
|
164
|
+
// passthrough; every VALUE is URLSearchParams-encoded, incl. the bracket keys
|
|
165
|
+
// `state[id]`/`ntee[id]`). ──
|
|
166
|
+
const params = new URLSearchParams();
|
|
167
|
+
const filtersApplied = [];
|
|
168
|
+
if (args.query !== undefined && args.query !== "") {
|
|
169
|
+
params.set("q", args.query);
|
|
170
|
+
filtersApplied.push("query");
|
|
171
|
+
}
|
|
172
|
+
if (args.state !== undefined) {
|
|
173
|
+
params.set("state[id]", args.state.toUpperCase());
|
|
174
|
+
filtersApplied.push("state");
|
|
175
|
+
}
|
|
176
|
+
if (args.ntee !== undefined) {
|
|
177
|
+
params.set("ntee[id]", String(args.ntee));
|
|
178
|
+
filtersApplied.push("ntee");
|
|
179
|
+
}
|
|
180
|
+
params.set("page", String(page));
|
|
181
|
+
const url = `https://${NONPROFIT_HOST}${NONPROFIT_BASE}/search.json?${params.toString()}`;
|
|
182
|
+
assertOnHost(url, label);
|
|
183
|
+
// ── Fetch through the shared envelope. redirect:"error" fails closed on any
|
|
184
|
+
// off-host 3xx. A 4xx ⇒ invalid_input; a 5xx/timeout ⇒ upstream_unavailable
|
|
185
|
+
// THROW; a 429 ⇒ rate_limited THROW; a 200 non-JSON ⇒ getJson's r.json()
|
|
186
|
+
// throws a SyntaxError ⇒ schema_drift. ──
|
|
187
|
+
let body;
|
|
188
|
+
try {
|
|
189
|
+
body = await getJson(url, { label, redirect: "error" });
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (e instanceof SyntaxError) {
|
|
193
|
+
throw driftError(label, "ProPublica Nonprofit search returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).");
|
|
194
|
+
}
|
|
195
|
+
throw e; // 5xx → upstream_unavailable, 4xx → invalid_input, 429 → rate_limited …
|
|
196
|
+
}
|
|
197
|
+
// ── [P4] `organizations` MUST be an array and `total_results` MUST be a number
|
|
198
|
+
// (a missing/wrong-typed either is drift, never a fabricated empty/total). ──
|
|
199
|
+
const b = (body ?? {});
|
|
200
|
+
if (!Array.isArray(b.organizations)) {
|
|
201
|
+
throw driftError(label, "ProPublica Nonprofit search shape drift — `organizations` must be an array.");
|
|
202
|
+
}
|
|
203
|
+
if (typeof b.total_results !== "number" || !Number.isFinite(b.total_results)) {
|
|
204
|
+
throw driftError(label, "ProPublica Nonprofit search shape drift — `total_results` (the total match count) must be a number.");
|
|
205
|
+
}
|
|
206
|
+
const organizations = b.organizations.map(mapOrgSummary);
|
|
207
|
+
const returned = organizations.length;
|
|
208
|
+
// ── [P1] totalAvailable is the API's REAL total_results, NEVER organizations.length.
|
|
209
|
+
// Page-based + 0-INDEXED: hasMore = (cur_page+1) < num_pages; surface the next
|
|
210
|
+
// page. cur_page/num_pages/per_page/page_offset via num() (defensive fallbacks). ──
|
|
211
|
+
const totalAvailable = b.total_results;
|
|
212
|
+
const curPage = num(b.cur_page) ?? page;
|
|
213
|
+
const numPages = num(b.num_pages);
|
|
214
|
+
const perPage = num(b.per_page) ?? (returned > 0 ? returned : FALLBACK_PER_PAGE);
|
|
215
|
+
const hasMore = numPages !== null ? curPage + 1 < numPages : false;
|
|
216
|
+
const offset = num(b.page_offset) ?? curPage * perPage;
|
|
217
|
+
const nextOffset = hasMore ? (curPage + 1) * perPage : null;
|
|
218
|
+
const notes = [PROVENANCE_NOTE, SEARCH_TOTAL_NOTE];
|
|
219
|
+
if (hasMore && numPages !== null) {
|
|
220
|
+
notes.push(`This is page ${curPage} (0-indexed) of ${numPages} — pass page=${curPage + 1} for the next page.`);
|
|
221
|
+
}
|
|
222
|
+
return withMeta({ organizations }, {
|
|
223
|
+
source: `${NONPROFIT_HOST} /nonprofits/api/v2/search (IRS Form 990 data via ProPublica Nonprofit Explorer — not a .gov API; keyless)`,
|
|
224
|
+
keylessMode: true,
|
|
225
|
+
returned,
|
|
226
|
+
totalAvailable,
|
|
227
|
+
filtersApplied,
|
|
228
|
+
filtersDropped: [],
|
|
229
|
+
fieldsUnavailable: [],
|
|
230
|
+
pagination: { offset, limit: perPage, hasMore, nextOffset },
|
|
231
|
+
notes,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Fetch ONE nonprofit's IRS Form 990 profile + financials via ProPublica Nonprofit
|
|
236
|
+
* Explorer (`/nonprofits/api/v2/organizations/{ein}.json`) → curated organization +
|
|
237
|
+
* filings + honest `_meta`. KEYLESS. ★PROVENANCE: ProPublica (a non-profit newsroom)
|
|
238
|
+
* republishing IRS Form 990 public records — NOT a .gov API. An unknown EIN (HTTP
|
|
239
|
+
* 404) ⇒ not_found (never a fabricated empty org). The four Form 990 figures ride
|
|
240
|
+
* num() (null-never-0). totalAvailable = filings.length (the COMPLETE set).
|
|
241
|
+
*/
|
|
242
|
+
export async function financials(args) {
|
|
243
|
+
const label = NONPROFIT_ORG_LABEL;
|
|
244
|
+
// ── Validate (belt-and-suspenders behind the server Zod). ein is charclass-
|
|
245
|
+
// guarded PRE-fetch — it rides the URL PATH, so it MUST be digits-only. ──
|
|
246
|
+
if (typeof args.ein !== "string" || !EIN_RE.test(args.ein)) {
|
|
247
|
+
throw new ToolErrorCarrier({
|
|
248
|
+
kind: "invalid_input",
|
|
249
|
+
retryable: false,
|
|
250
|
+
message: `Invalid ein ${JSON.stringify(args.ein)} — expected a numeric EIN of 1..9 digits (^\\d{1,9}$), e.g. "530196605".`,
|
|
251
|
+
upstreamEndpoint: label,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// ein is digits-only (EIN_RE) ⇒ safe as a path segment; no separators can steer
|
|
255
|
+
// the authority. Build + re-assert the host (SSRF belt-and-suspenders).
|
|
256
|
+
const url = `https://${NONPROFIT_HOST}${NONPROFIT_BASE}/organizations/${args.ein}.json`;
|
|
257
|
+
assertOnHost(url, label);
|
|
258
|
+
// ── Fetch through the shared envelope. A 404 (unknown EIN) ⇒ not_found (the
|
|
259
|
+
// shared taxonomy — never a fabricated empty org); a 4xx ⇒ invalid_input; a
|
|
260
|
+
// 5xx/timeout ⇒ upstream_unavailable THROW; a 200 non-JSON ⇒ schema_drift. ──
|
|
261
|
+
let body;
|
|
262
|
+
try {
|
|
263
|
+
body = await getJson(url, { label, redirect: "error" });
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
if (e instanceof SyntaxError) {
|
|
267
|
+
throw driftError(label, "ProPublica Nonprofit organization detail returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).");
|
|
268
|
+
}
|
|
269
|
+
throw e; // 404 → not_found, 5xx → upstream_unavailable, 4xx → invalid_input …
|
|
270
|
+
}
|
|
271
|
+
// ── [P4] `organization` MUST be an object and `filings_with_data` MUST be an
|
|
272
|
+
// array (a missing/wrong-typed either is drift, never a fabricated empty). ──
|
|
273
|
+
const b = (body ?? {});
|
|
274
|
+
if (b.organization === null ||
|
|
275
|
+
typeof b.organization !== "object" ||
|
|
276
|
+
Array.isArray(b.organization)) {
|
|
277
|
+
throw driftError(label, "ProPublica Nonprofit organization detail shape drift — `organization` must be an object.");
|
|
278
|
+
}
|
|
279
|
+
if (!Array.isArray(b.filings_with_data)) {
|
|
280
|
+
throw driftError(label, "ProPublica Nonprofit organization detail shape drift — `filings_with_data` must be an array.");
|
|
281
|
+
}
|
|
282
|
+
const organization = mapOrganization(b.organization);
|
|
283
|
+
const filings = b.filings_with_data.map(mapFiling);
|
|
284
|
+
const returned = filings.length;
|
|
285
|
+
// ── [P2] ★not-found SENTINEL: ProPublica returns HTTP 200 + a synthetic
|
|
286
|
+
// `{ name:"Unknown Organization", …all-null }` placeholder (zero
|
|
287
|
+
// filings_with_data) for an in-range EIN with no IRS record. That is a
|
|
288
|
+
// FABRICATED empty org — surface it as not_found (identical to a 404), NEVER
|
|
289
|
+
// as a real hit. Gated on BOTH the exact sentinel name AND empty structured
|
|
290
|
+
// filings, so a real org (which would carry its true name / filings) is safe. ──
|
|
291
|
+
if (organization.name === PROPUBLICA_NOT_FOUND_NAME && returned === 0) {
|
|
292
|
+
throw new ToolErrorCarrier({
|
|
293
|
+
kind: "not_found",
|
|
294
|
+
retryable: false,
|
|
295
|
+
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.`,
|
|
296
|
+
upstreamEndpoint: label,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// ── [P1] totalAvailable = filings.length — the COMPLETE filing set from the one
|
|
300
|
+
// detail document (no pagination). ──
|
|
301
|
+
const notes = [
|
|
302
|
+
PROVENANCE_NOTE,
|
|
303
|
+
FINANCIALS_TOTAL_NOTE,
|
|
304
|
+
FINANCIALS_MONEY_NOTE,
|
|
305
|
+
];
|
|
306
|
+
return withMeta({ organization, filings }, {
|
|
307
|
+
source: `${NONPROFIT_HOST} /nonprofits/api/v2/organizations (IRS Form 990 data via ProPublica Nonprofit Explorer — not a .gov API; keyless)`,
|
|
308
|
+
keylessMode: true,
|
|
309
|
+
returned,
|
|
310
|
+
totalAvailable: returned,
|
|
311
|
+
filtersApplied: [],
|
|
312
|
+
filtersDropped: [],
|
|
313
|
+
fieldsUnavailable: [],
|
|
314
|
+
// The complete set is in one document — no pagination, hasMore:false.
|
|
315
|
+
pagination: { offset: 0, limit: returned, hasMore: false, nextOffset: null },
|
|
316
|
+
notes,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// ─── SSRF host assert (shared by both tools) ──────────────────────
|
|
320
|
+
/**
|
|
321
|
+
* Belt-and-suspenders: the fixed host + strictly-built URL leave nothing to steer
|
|
322
|
+
* the authority; assert the built URL cannot have been moved off-host / downgraded.
|
|
323
|
+
*/
|
|
324
|
+
function assertOnHost(url, label) {
|
|
325
|
+
const built = new URL(url);
|
|
326
|
+
if (built.hostname !== NONPROFIT_HOST || built.protocol !== "https:") {
|
|
327
|
+
throw new ToolErrorCarrier({
|
|
328
|
+
kind: "invalid_input",
|
|
329
|
+
retryable: false,
|
|
330
|
+
message: `Constructed ProPublica Nonprofit URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${NONPROFIT_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
331
|
+
upstreamEndpoint: label,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// ─── Small clamp (defensive, behind the server Zod bounds) ─────────
|
|
336
|
+
function clampPage(v) {
|
|
337
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
338
|
+
return DEFAULT_PAGE;
|
|
339
|
+
const n = Math.floor(v);
|
|
340
|
+
return n < 0 ? 0 : n;
|
|
341
|
+
}
|
|
342
|
+
//# sourceMappingURL=nonprofit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonprofit.js","sourceRoot":"","sources":["../src/nonprofit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openfda-device.ts — openFDA 510(k) DEVICE CLEARANCES (api.fda.gov) — the
|
|
3
|
+
* MEDICAL-DEVICE REGULATORY lane (ADR-0056). The FDA's premarket-notification
|
|
4
|
+
* (510(k)) clearances: the applicant, the device, the clearance number (K-number),
|
|
5
|
+
* the decision (date + description), the clearance type, the product code, the
|
|
6
|
+
* advisory committee, and geography.
|
|
7
|
+
*
|
|
8
|
+
* ★ THIS IS THE SAME SOURCE + ENVELOPE + CRUX as openfda.ts (ADR-0054). Same host
|
|
9
|
+
* (api.fda.gov), same response envelope `{ meta:{ results:{ skip, limit, total }},
|
|
10
|
+
* results:[…] }`, same ★no-match→HTTP-404-NOT_FOUND-as-honest-empty crux, same
|
|
11
|
+
* optional query-key K-test, same fixed-host SSRF idiom, same structured-only
|
|
12
|
+
* (no raw Lucene passthrough) search assembly. It REUSES openfda.ts's `fetchOpenfda`
|
|
13
|
+
* (the SSRF-guarded classified fetch) and `readOpenfdaError` (the error-body reader)
|
|
14
|
+
* verbatim — the 404-reclassification logic is NOT reinvented — plus `luceneQuote`
|
|
15
|
+
* (the phrase-escape), `openfdaApiKey` (the optional-key env seam), and `OPENFDA_HOST`.
|
|
16
|
+
*
|
|
17
|
+
* GET https://api.fda.gov/device/510k.json
|
|
18
|
+
* ?search=<lucene>&limit=<1..100>&skip=<offset>[&api_key=<KEY>]
|
|
19
|
+
* → { meta:{ disclaimer, results:{ skip, limit, total }}, results:[ {…} ] }
|
|
20
|
+
* (live total ~175507).
|
|
21
|
+
*
|
|
22
|
+
* ★ HONESTY (mirrors openfda.ts exactly):
|
|
23
|
+
* [P1] totalAvailable = `meta.results.total` EXACT (e.g. 175507), NEVER
|
|
24
|
+
* results.length. skip/limit offset pagination:
|
|
25
|
+
* hasMore = skip + returned < total; nextOffset = hasMore ? skip+returned : null.
|
|
26
|
+
* [★P2] a 404 whose body is `{error:{code:"NOT_FOUND"}}` (a no-match query OR an
|
|
27
|
+
* unknown field) ⇒ HONEST EMPTY (returned:0, totalAvailable:0) — NOT thrown,
|
|
28
|
+
* NOT not_found. Any OTHER 4xx (e.g. a 400 syntax error) ⇒ invalid_input
|
|
29
|
+
* surfacing openFDA's message. 5xx/timeout ⇒ upstream_unavailable THROW. A
|
|
30
|
+
* 200 non-JSON body ⇒ schema_drift. (Reuses openfda.ts's exact fetch path.)
|
|
31
|
+
* [P3] dates (`decision_date`, YYYY-MM-DD) and every scalar surfaced as a STRING
|
|
32
|
+
* via `str` (null-never-empty-string) — no numeric coercion; never fabricated.
|
|
33
|
+
* [P4] `meta.results` or `results` absent / non-array ⇒ driftError (never a
|
|
34
|
+
* fabricated empty).
|
|
35
|
+
* [K-test] OPTIONAL OPENFDA_API_KEY: when set it rides `&api_key=` ONLY (openFDA has
|
|
36
|
+
* no header option — the query-key is inherent). Mitigation: the `label` is
|
|
37
|
+
* host+path ONLY (`openfda:/device/510k`, NO query), so no token reaches
|
|
38
|
+
* ToolError.upstreamEndpoint; `_meta.source` names the MODE only; the key is
|
|
39
|
+
* ABSENT from the serialized {data,_meta}, notes, and any log. Unset ⇒ keyless.
|
|
40
|
+
* [SSRF] fixed host `api.fda.gov` (fetchOpenfda asserts hostname/protocol +
|
|
41
|
+
* redirect:"error"); all filter VALUES are Lucene-escaped + phrase-quoted and
|
|
42
|
+
* ride URLSearchParams `search=`; limit/skip are integers; state is charclass
|
|
43
|
+
* `^[A-Za-z]{2}$`. No raw Lucene passthrough — structured only, injection-safe.
|
|
44
|
+
*/
|
|
45
|
+
import { type MetaBundle } from "./meta.js";
|
|
46
|
+
export type OpenfdaClearance = {
|
|
47
|
+
applicant: string | null;
|
|
48
|
+
deviceName: string | null;
|
|
49
|
+
kNumber: string | null;
|
|
50
|
+
decisionDate: string | null;
|
|
51
|
+
decisionDescription: string | null;
|
|
52
|
+
clearanceType: string | null;
|
|
53
|
+
productCode: string | null;
|
|
54
|
+
advisoryCommittee: string | null;
|
|
55
|
+
state: string | null;
|
|
56
|
+
};
|
|
57
|
+
/** The structured filter set → openFDA `field:value` clauses (510(k) fields). */
|
|
58
|
+
export type OpenfdaDeviceFilters = {
|
|
59
|
+
applicant?: string;
|
|
60
|
+
deviceName?: string;
|
|
61
|
+
productCode?: string;
|
|
62
|
+
clearanceType?: string;
|
|
63
|
+
kNumber?: string;
|
|
64
|
+
state?: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Assemble the openFDA `search=` Lucene string from structured 510(k) filters — each
|
|
68
|
+
* value Lucene-escaped + phrase-quoted (reusing openfda.ts's `luceneQuote`), joined by
|
|
69
|
+
* ` AND `. Returns "" when no filter is present (openFDA then returns the whole
|
|
70
|
+
* collection). The clause → field mapping is FIXED here; a caller can never inject a
|
|
71
|
+
* raw field:value (no raw Lucene passthrough).
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildDeviceSearch(f: OpenfdaDeviceFilters): string;
|
|
74
|
+
export type OpenfdaDeviceClearancesArgs = OpenfdaDeviceFilters & {
|
|
75
|
+
limit?: number;
|
|
76
|
+
skip?: number;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Search openFDA 510(k) DEVICE CLEARANCES with structured filters → curated clearance
|
|
80
|
+
* rows + honest `_meta`. KEYLESS (an OPTIONAL OPENFDA_API_KEY only raises the rate
|
|
81
|
+
* limit). totalAvailable = meta.results.total (EXACT); skip/limit offset pagination.
|
|
82
|
+
* ★A no-match query (openFDA HTTP 404 NOT_FOUND) ⇒ an honest empty, never a throw.
|
|
83
|
+
*/
|
|
84
|
+
export declare function deviceClearances(args: OpenfdaDeviceClearancesArgs): Promise<MetaBundle>;
|
|
85
|
+
//# sourceMappingURL=openfda-device.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openfda-device.d.ts","sourceRoot":"","sources":["../src/openfda-device.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAKH,OAAO,EAAY,KAAK,UAAU,EAAqB,MAAM,WAAW,CAAC;AA6BzE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,CAAC;AAmBF,iFAAiF;AACjF,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,oBAAoB,GAAG,MAAM,CAejE;AAGD,MAAM,MAAM,2BAA2B,GAAG,oBAAoB,GAAG;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,2BAA2B,GAChC,OAAO,CAAC,UAAU,CAAC,CAyJrB"}
|