@cliwant/mcp-sam-gov 1.2.0 → 1.3.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/src/lda.ts ADDED
@@ -0,0 +1,385 @@
1
+ /**
2
+ * lda.ts — US Senate LDA (Lobbying Disclosure Act) filings — the LOBBYING / B2G
3
+ * influence lane (ADR-0052). Who is paid HOW MUCH to lobby WHICH federal agency
4
+ * on WHICH issue — the registrant→client→government-entity signal that no
5
+ * contract/spending/grant source carries.
6
+ *
7
+ * ★ THIS IS A KEYLESS SOURCE WITH AN OPTIONAL KEY (the socrata.ts app-token
8
+ * lineage, NOT the census/fred/bea key-required lineage). The LDA REST API
9
+ * (lda.senate.gov/api/v1) serves anonymous GETs at HTTP 200 — it works with NO
10
+ * key. A free `LDA_API_KEY` only RAISES the shared rate limit; when set it rides
11
+ * ONLY as the `Authorization: Token <value>` request header (never the
12
+ * URL/label/_meta/notes/log — the K-test). When unset, NO auth header is sent
13
+ * (genuine keyless). This mirrors socrata's optional `X-App-Token` discipline.
14
+ *
15
+ * The module writes ZERO fetch/coercion/error/meta code of its own: it REUSES
16
+ * `getJson` (the shared fetch envelope, redirect:"error") / `driftError` /
17
+ * `num`·`str` (coerce.ts, null-never-0/empty) / `withMeta`·`buildMeta`.
18
+ *
19
+ * GET https://lda.senate.gov/api/v1/filings/
20
+ * ?filing_year=&filing_type=&registrant_name=&client_name=&lobbyist_name=
21
+ * &filing_specific_lobbying_issues=&government_entity=&page=&page_size=
22
+ * → { count, next, previous, results:[{ filing_uuid, filing_type,
23
+ * filing_type_display, filing_year, filing_period, filing_period_display,
24
+ * filing_document_url, income, expenses, dt_posted, termination_date,
25
+ * registrant:{name,…}|string, client:{name,…}|string,
26
+ * lobbying_activities:[{ general_issue_code, general_issue_code_display,
27
+ * description, government_entities:[{ name }] }], … }] }
28
+ *
29
+ * ★ HONESTY (ADR-0052 P1–P5):
30
+ * [P1] `count` is the API's REAL total (~1.95M) ⇒ totalAvailable = num(count),
31
+ * NEVER results.length. Page-based pagination (page/page_size, 1-based):
32
+ * returned = results.length, hasMore = page*pageSize < count, and the next
33
+ * page number is surfaced in a note. Reverting totalAvailable to
34
+ * results.length must go RED.
35
+ * [P2] getJson → fetchWithRetry taxonomy: a 400 (bad filter/param) ⇒
36
+ * invalid_input SURFACING the API's error body (re-read once on the error
37
+ * path); a 5xx/timeout ⇒ upstream_unavailable THROW; a 429 ⇒ rate_limited
38
+ * THROW honoring Retry-After (NEVER routed around); a genuine no-match
39
+ * (results:[]) ⇒ honest empty (returned:0); a 200 non-JSON ⇒ schema_drift.
40
+ * [P3] `income`/`expenses` are null-or-decimal-string ⇒ num() (null ⇒ null — NOT
41
+ * reported ≠ 0; a genuine "0" stays 0). A missing `lobbying_activities` /
42
+ * `government_entities` ⇒ empty arrays (never fabricated).
43
+ * [P4] `results` non-array or `count` non-number ⇒ driftError (never a
44
+ * fabricated empty/total).
45
+ * [SSRF] fixed host `lda.senate.gov`; a post-construction hostname/protocol
46
+ * assert + `redirect:"error"`; every filter VALUE rides URLSearchParams;
47
+ * `filingYear` / `page` / `pageSize` are charclass/range-guarded pre-fetch.
48
+ */
49
+
50
+ import { ToolErrorCarrier } from "./errors.js";
51
+ import { getJson, driftError } from "./datasource.js";
52
+ import { num, str } from "./coerce.js";
53
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
54
+
55
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
56
+ // ADR-0005 v2 FIX-C) so a `num` regression fails together across sources.
57
+ export { num };
58
+
59
+ // ─── SSRF core: the single fixed host + base path ─────────────────
60
+ export const LDA_HOST = "lda.senate.gov";
61
+ const LDA_FILINGS_PATH = "/api/v1/filings/";
62
+ // HOST+path label — surfaces in ToolError.upstreamEndpoint; the optional key rides
63
+ // ONLY in the Authorization header, so no token can ever appear here.
64
+ const LDA_FILINGS_LABEL = "lda:/api/v1/filings";
65
+
66
+ // ─── Validation (SSRF + "verify the input" honesty) ───────────────
67
+ const YEAR_RE = /^\d{4}$/; // a single 4-digit filing year
68
+ const DEFAULT_PAGE = 1;
69
+ const DEFAULT_PAGE_SIZE = 25;
70
+ const MAX_PAGE_SIZE = 25; // the LDA API caps page_size at 25
71
+
72
+ // ─── Honesty notes (ADR-0052 required set) ────────────────────────
73
+ const KEYLESS_NOTE =
74
+ "Keyless by default (anonymous LDA API access returns HTTP 200). An optional free LDA_API_KEY only RAISES the rate limit; when set it is sent ONLY as the `Authorization: Token …` request header and is NEVER logged, echoed, or placed in this response.";
75
+ const AMOUNT_NOTE =
76
+ "incomeUsd / expensesUsd are parsed from the API's null-or-decimal-string income / expenses. A null (not reported) maps to null — NEVER 0; a genuine reported 0 is preserved as 0. A filing reports EITHER income (lobbying firms) OR expenses (in-house filers), so the other is typically null.";
77
+ const COUNT_TOTAL_NOTE =
78
+ "totalAvailable is the LDA API's real total match count for the query (the whole corpus is ~1.95M filings) — NOT the number of rows on this page. Pagination is page-based (page / pageSize, 1-based); pass the next page number for more.";
79
+
80
+ // ─── The optional-key seam (value NEVER leaked past the Authorization header) ──
81
+ /**
82
+ * The optional Authorization header (keyless-first, socrata app-token lineage).
83
+ * Present ONLY when LDA_API_KEY is set (non-blank); the value is NEVER logged /
84
+ * never placed in the URL, label, `_meta`, or a note. When unset, `{}` (no header).
85
+ */
86
+ export function ldaAuthHeader(): Record<string, string> {
87
+ const raw = process.env.LDA_API_KEY;
88
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
89
+ return trimmed ? { Authorization: `Token ${trimmed}` } : {};
90
+ }
91
+
92
+ /** true iff an LDA API key is configured (for the `_meta` note — never the value). */
93
+ export function ldaKeyPresent(): boolean {
94
+ const raw = process.env.LDA_API_KEY;
95
+ return typeof raw === "string" && raw.trim().length > 0;
96
+ }
97
+
98
+ // ─── Curated filing shape ─────────────────────────────────────────
99
+ export type LdaLobbyingActivity = {
100
+ issueCode: string | null; // general_issue_code (the short code, e.g. "TAX")
101
+ description: string | null; // description (the free-text issue narrative)
102
+ governmentEntities: string[]; // government_entities[].name — the B2G targets
103
+ };
104
+
105
+ export type LdaFiling = {
106
+ filingUuid: string | null;
107
+ filingType: string | null; // filing_type (the short code, e.g. "Q1")
108
+ filingYear: number | null; // filing_year
109
+ filingPeriod: string | null; // filing_period_display ?? filing_period
110
+ incomeUsd: number | null; // income (null-or-decimal-string) — null ≠ 0
111
+ expensesUsd: number | null; // expenses (null-or-decimal-string) — null ≠ 0
112
+ registrant: string | null; // registrant name (object-with-name OR string)
113
+ client: string | null; // client name (object-with-name OR string)
114
+ lobbyingActivities: LdaLobbyingActivity[];
115
+ documentUrl: string | null; // filing_document_url
116
+ postedDate: string | null; // dt_posted
117
+ terminationDate: string | null; // termination_date
118
+ };
119
+
120
+ /**
121
+ * The registrant/client value may be an OBJECT carrying `name` (with address
122
+ * fields) OR a plain string. Defensively resolve either to the display name (or
123
+ * null when absent) — never fabricate, never surface the whole address object.
124
+ */
125
+ export function nameOf(x: unknown): string | null {
126
+ if (x === null || x === undefined) return null;
127
+ if (typeof x === "string") return str(x);
128
+ if (typeof x === "object") return str((x as Record<string, unknown>).name);
129
+ return null;
130
+ }
131
+
132
+ /** Map ONE lobbying_activities row → the curated shape (missing arrays ⇒ []). */
133
+ function mapActivity(raw: unknown): LdaLobbyingActivity {
134
+ const a = (raw ?? {}) as Record<string, unknown>;
135
+ const entities = Array.isArray(a.government_entities)
136
+ ? (a.government_entities as unknown[])
137
+ .map((e) => str((e as Record<string, unknown> | null)?.name))
138
+ .filter((n): n is string => n !== null)
139
+ : [];
140
+ return {
141
+ issueCode: str(a.general_issue_code),
142
+ description: str(a.description),
143
+ governmentEntities: entities,
144
+ };
145
+ }
146
+
147
+ /** Map ONE `results[]` filing row → the curated LdaFiling shape. */
148
+ function mapFiling(raw: unknown): LdaFiling {
149
+ const f = (raw ?? {}) as Record<string, unknown>;
150
+ return {
151
+ filingUuid: str(f.filing_uuid),
152
+ filingType: str(f.filing_type),
153
+ filingYear: num(f.filing_year),
154
+ filingPeriod: str(f.filing_period_display) ?? str(f.filing_period),
155
+ // [P3] null (not reported) ⇒ null, NEVER 0; a genuine "0" ⇒ 0.
156
+ incomeUsd: num(f.income),
157
+ expensesUsd: num(f.expenses),
158
+ registrant: nameOf(f.registrant),
159
+ client: nameOf(f.client),
160
+ lobbyingActivities: Array.isArray(f.lobbying_activities)
161
+ ? (f.lobbying_activities as unknown[]).map(mapActivity)
162
+ : [],
163
+ documentUrl: str(f.filing_document_url),
164
+ postedDate: str(f.dt_posted),
165
+ terminationDate: str(f.termination_date),
166
+ };
167
+ }
168
+
169
+ // ─── Tool: lda_search_filings ─────────────────────────────────────
170
+ export type LdaSearchFilingsArgs = {
171
+ registrantName?: string;
172
+ clientName?: string;
173
+ lobbyistName?: string;
174
+ filingYear?: string; // ^\d{4}$
175
+ filingType?: string; // a short code (e.g. "Q1", "RR")
176
+ agency?: string; // → government_entity (the federal entity lobbied)
177
+ issue?: string; // → filing_specific_lobbying_issues
178
+ page?: number; // 1-based, default 1
179
+ pageSize?: number; // 1..25, default 25
180
+ };
181
+
182
+ /**
183
+ * Search US Senate LDA lobbying filings (`/api/v1/filings/`) → curated filing rows
184
+ * + honest `_meta`. KEYLESS (an optional LDA_API_KEY only raises the rate limit,
185
+ * sent as the Authorization header only). ★totalAvailable is the API's REAL `count`
186
+ * (~1.95M corpus) — never results.length; page-based pagination. income/expenses
187
+ * null-or-decimal-string → null-never-0.
188
+ */
189
+ export async function searchFilings(
190
+ args: LdaSearchFilingsArgs,
191
+ ): Promise<MetaBundle> {
192
+ // ── Validate + default (belt-and-suspenders behind the server Zod; a DIRECT
193
+ // handler call bypasses Zod). filingYear / page / pageSize are charclass/
194
+ // range-guarded; the free-text filters ride URLSearchParams (encoded). ──
195
+ if (args.filingYear !== undefined && !YEAR_RE.test(args.filingYear)) {
196
+ throw new ToolErrorCarrier({
197
+ kind: "invalid_input",
198
+ retryable: false,
199
+ message: `Invalid filingYear ${JSON.stringify(args.filingYear)} — expected a 4-digit year (^\\d{4}$), e.g. "2024".`,
200
+ upstreamEndpoint: LDA_FILINGS_LABEL,
201
+ });
202
+ }
203
+ const page = clampPage(args.page);
204
+ const pageSize = clampPageSize(args.pageSize);
205
+
206
+ // ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
207
+ // passthrough; every VALUE is URLSearchParams-encoded). ──
208
+ const params = new URLSearchParams();
209
+ const filtersApplied: string[] = [];
210
+ const setFilter = (key: string, val: string | undefined, label: string) => {
211
+ if (val !== undefined && val !== "") {
212
+ params.set(key, val);
213
+ filtersApplied.push(label);
214
+ }
215
+ };
216
+ setFilter("registrant_name", args.registrantName, "registrantName");
217
+ setFilter("client_name", args.clientName, "clientName");
218
+ setFilter("lobbyist_name", args.lobbyistName, "lobbyistName");
219
+ setFilter("filing_year", args.filingYear, "filingYear");
220
+ setFilter("filing_type", args.filingType, "filingType");
221
+ setFilter("government_entity", args.agency, "agency");
222
+ setFilter("filing_specific_lobbying_issues", args.issue, "issue");
223
+ params.set("page", String(page));
224
+ params.set("page_size", String(pageSize));
225
+
226
+ const url = `https://${LDA_HOST}${LDA_FILINGS_PATH}?${params.toString()}`;
227
+ // Belt-and-suspenders: the fixed host + strictly-built query leave nothing to
228
+ // steer the authority; assert the built URL cannot have been moved off-host.
229
+ const built = new URL(url);
230
+ if (built.hostname !== LDA_HOST || built.protocol !== "https:") {
231
+ throw new ToolErrorCarrier({
232
+ kind: "invalid_input",
233
+ retryable: false,
234
+ message: `Constructed LDA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${LDA_HOST} over https — refusing to fetch (SSRF safety).`,
235
+ upstreamEndpoint: LDA_FILINGS_LABEL,
236
+ });
237
+ }
238
+
239
+ // ── Fetch through the shared envelope. The optional key rides the Authorization
240
+ // header ONLY (never the URL/label/_meta); redirect:"error" fails closed on
241
+ // any off-host 3xx. A 429 ⇒ rate_limited THROW (Retry-After honored by the
242
+ // shared taxonomy, never routed around); a 5xx/timeout ⇒ upstream_unavailable
243
+ // THROW; a 400 ⇒ invalid_input (re-read below to surface the API message); a
244
+ // 200 non-JSON ⇒ getJson's r.json() throws a SyntaxError ⇒ schema_drift. ──
245
+ const headers = ldaAuthHeader();
246
+ let body: unknown;
247
+ try {
248
+ body = await getJson<unknown>(url, {
249
+ label: LDA_FILINGS_LABEL,
250
+ headers,
251
+ redirect: "error",
252
+ });
253
+ } catch (e) {
254
+ if (e instanceof SyntaxError) {
255
+ throw driftError(
256
+ LDA_FILINGS_LABEL,
257
+ "LDA /api/v1/filings returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
258
+ );
259
+ }
260
+ // [P2] A 400 (bad filter/param) carries a DRF error body; fetchWithRetry
261
+ // discarded it. Re-read once on the error path ONLY so the caller learns the
262
+ // REAL reason (never a fake-empty). 5xx/429/404/timeout keep their taxonomy.
263
+ if (e instanceof ToolErrorCarrier && e.toolError.upstreamStatus === 400) {
264
+ const apiMsg = await readLdaErrorMessage(url, headers);
265
+ throw new ToolErrorCarrier({
266
+ kind: "invalid_input",
267
+ retryable: false,
268
+ message: apiMsg
269
+ ? `LDA rejected the request (HTTP 400): ${apiMsg}. Check the filter parameters (filingYear, filingType, agency, issue, …).`
270
+ : "LDA rejected the request (HTTP 400) — check the filter parameters (filingYear, filingType, agency, issue, …).",
271
+ upstreamStatus: 400,
272
+ upstreamEndpoint: LDA_FILINGS_LABEL,
273
+ });
274
+ }
275
+ throw e; // 5xx → upstream_unavailable, 404 → not_found, 429 → rate_limited …
276
+ }
277
+
278
+ // ── [P4] `results` MUST be an array and `count` MUST be a number (a missing/
279
+ // wrong-typed either is drift, never a fabricated empty/total). ──
280
+ const b = (body ?? {}) as { results?: unknown; count?: unknown };
281
+ if (!Array.isArray(b.results)) {
282
+ throw driftError(
283
+ LDA_FILINGS_LABEL,
284
+ "LDA /api/v1/filings shape drift — `results` must be an array.",
285
+ );
286
+ }
287
+ if (typeof b.count !== "number" || !Number.isFinite(b.count)) {
288
+ throw driftError(
289
+ LDA_FILINGS_LABEL,
290
+ "LDA /api/v1/filings shape drift — `count` (the total match count) must be a number.",
291
+ );
292
+ }
293
+
294
+ const filings = (b.results as unknown[]).map(mapFiling);
295
+ const returned = filings.length;
296
+
297
+ // ── [P1] totalAvailable is the API's REAL count (~1.95M), NEVER results.length.
298
+ // Page-based: hasMore = page*pageSize < count; the next page is surfaced. ──
299
+ const totalAvailable = b.count;
300
+ const offset = (page - 1) * pageSize;
301
+ const hasMore = page * pageSize < totalAvailable;
302
+ const nextOffset = hasMore ? page * pageSize : null;
303
+
304
+ const notes: string[] = [COUNT_TOTAL_NOTE, AMOUNT_NOTE, KEYLESS_NOTE];
305
+ notes.push(
306
+ `LDA API key: ${ldaKeyPresent() ? "present (Authorization: Token … sent; value never logged)" : "absent (keyless; a free LDA_API_KEY lifts the rate limit)"}.`,
307
+ );
308
+ if (hasMore) {
309
+ notes.push(
310
+ `This is page ${page} (pageSize ${pageSize}) of ~${Math.ceil(totalAvailable / pageSize)} — pass page=${page + 1} for the next page.`,
311
+ );
312
+ }
313
+
314
+ return withMeta(
315
+ { filings },
316
+ {
317
+ source: `${LDA_HOST} /api/v1/filings (US Senate LDA lobbying; keyless)`,
318
+ keylessMode: true, // ★KEYLESS — the optional key only raises the rate limit
319
+ returned,
320
+ totalAvailable,
321
+ filtersApplied,
322
+ filtersDropped: [],
323
+ fieldsUnavailable: [],
324
+ pagination: { offset, limit: pageSize, hasMore, nextOffset },
325
+ notes,
326
+ } satisfies Partial<ResponseMeta>,
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Single bare GET to read an LDA 400's DRF error body (error path ONLY). Returns a
332
+ * compact human-readable message, or null on any failure. Sends the SAME headers
333
+ * (so a keyed re-read honors the key) + redirect:"error"; the key stays header-only.
334
+ */
335
+ async function readLdaErrorMessage(
336
+ url: string,
337
+ headers: Record<string, string>,
338
+ ): Promise<string | null> {
339
+ try {
340
+ const r = await fetch(url, {
341
+ signal: AbortSignal.timeout(15_000),
342
+ headers,
343
+ redirect: "error",
344
+ });
345
+ const body = (await r.json()) as unknown;
346
+ return summarizeDrfError(body);
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Summarize a Django-REST-Framework 400 error body into a compact string. DRF
354
+ * emits `{ detail: "…" }` OR `{ field: ["message", …], … }`. Returns null for a
355
+ * shape we can't read (⇒ the caller falls back to the generic 400 message).
356
+ */
357
+ function summarizeDrfError(body: unknown): string | null {
358
+ if (typeof body === "string") return str(body);
359
+ if (body === null || typeof body !== "object") return null;
360
+ const obj = body as Record<string, unknown>;
361
+ if (typeof obj.detail === "string") return str(obj.detail);
362
+ const parts: string[] = [];
363
+ for (const [k, v] of Object.entries(obj)) {
364
+ const msg = Array.isArray(v)
365
+ ? v.map((x) => str(x)).filter((x): x is string => x !== null).join("; ")
366
+ : str(v);
367
+ if (msg) parts.push(`${k}: ${msg}`);
368
+ }
369
+ return parts.length > 0 ? parts.join(" | ") : null;
370
+ }
371
+
372
+ // ─── Small clamps (defensive, behind the server Zod bounds) ────────
373
+ function clampPage(v: unknown): number {
374
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_PAGE;
375
+ const n = Math.floor(v);
376
+ return n < 1 ? 1 : n;
377
+ }
378
+
379
+ function clampPageSize(v: unknown): number {
380
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_PAGE_SIZE;
381
+ const n = Math.floor(v);
382
+ if (n < 1) return 1;
383
+ if (n > MAX_PAGE_SIZE) return MAX_PAGE_SIZE;
384
+ return n;
385
+ }