@cliwant/mcp-sam-gov 1.12.0 → 1.13.1

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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * tableau.ts — Tableau Server "Guest" view CSV export, a keyless-first SLED
3
+ * transparency source (loop cycle 75, 2026-07-24 — Montana dark-state closure).
4
+ *
5
+ * WHAT IT ADDS: many US state/local governments publish contracts / vendor-payment
6
+ * / checkbook data on a Guest-enabled Tableau Server. A WORKSHEET view exports its
7
+ * FULL summary data as CSV at
8
+ * `https://{host}/t/{site}/views/{workbook}/{view}.csv?:embed=y`
9
+ * — anonymous, KEYLESS (no login, key, or session cookie required). This is the
10
+ * export the public Tableau UI itself offers ("Download ▸ Data"). First payload:
11
+ * Montana state **Contracts Awarded** (DOA), a live gov-con award register.
12
+ *
13
+ * ★ CURATED allowlist (SSRF core): each `base` is a FIXED, live-verified Tableau
14
+ * Server view URL (up to the view name; the tool appends `.csv?:embed=y`). The
15
+ * `view` enum in server.ts is built FROM the keys (single source of truth), and
16
+ * a post-construction hostname assertion (over https) guards the fetch
17
+ * (`redirect:"error"`). where/columns cannot alter the host.
18
+ *
19
+ * ★ HONESTY PILLARS:
20
+ * P1: the CSV is the COMPLETE view export — Tableau returns ALL summary rows in
21
+ * the view (there is NO server-side pagination on this endpoint), so
22
+ * totalAvailable = the parsed DATA-row count (the true total, NOT a page
23
+ * length); limit/offset page over it CLIENT-side. NOTE (disclosed every
24
+ * response): a Tableau Server MAY server-cap a very large summary export — the
25
+ * seeded views are live-verified COMPLETE (non-round counts), and a round-number
26
+ * count is flagged as a possible cap.
27
+ * P2: getText THROWS on 429 / 5xx / 404 / timeout — NEVER a fake empty. A view
28
+ * that is gated/renamed (a 200 sign-in HTML, or a dashboard-CONTAINER whose CSV
29
+ * export is empty) ⇒ schema_drift (a loud, honest failure — NEVER a silent 0).
30
+ * A worksheet that legitimately has a header but zero data rows ⇒ honest empty.
31
+ * P3: values are TRIMMED strings (surrounding whitespace removed; an empty field
32
+ * ⇒ null, never 0 or ""). The value CONTENT is preserved — amounts/dates are
33
+ * FORMATTED STRINGS (e.g. "$5,879,590.00"), parse client-side. Header trimmed.
34
+ * P4: a 200 body that is not CSV (HTML, or no header row) ⇒ schema_drift.
35
+ */
36
+ import { num } from "./coerce.js";
37
+ import { type MetaBundle } from "./meta.js";
38
+ export { num };
39
+ export type TableauView = {
40
+ key: string;
41
+ base: string;
42
+ label: string;
43
+ note: string;
44
+ };
45
+ export declare const TABLEAU_VIEWS: readonly TableauView[];
46
+ export type TableauViewCsvArgs = {
47
+ view: string;
48
+ limit?: number;
49
+ offset?: number;
50
+ };
51
+ /**
52
+ * Fetch a curated Tableau Server Guest view's COMPLETE CSV export (keyless) and
53
+ * page over it client-side. `view` is an allowlist enum; `limit`/`offset` page
54
+ * the parsed rows. Returns { view, columns, rows:[{col:value…}] } + honest _meta
55
+ * (totalAvailable = the complete row count, NOT a page length).
56
+ */
57
+ export declare function viewCsv(args: TableauViewCsvArgs): Promise<MetaBundle>;
58
+ //# sourceMappingURL=tableau.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tableau.d.ts","sourceRoot":"","sources":["../src/tableau.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH,OAAO,EAAE,GAAG,EAAO,MAAM,aAAa,CAAC;AAEvC,OAAO,EAAY,KAAK,UAAU,EAAqB,MAAM,WAAW,CAAC;AAEzE,OAAO,EAAE,GAAG,EAAE,CAAC;AAKf,MAAM,MAAM,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AACrF,eAAO,MAAM,aAAa,EAAE,SAAS,WAAW,EAOtC,CAAC;AAwBX,MAAM,MAAM,kBAAkB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnF;;;;;GAKG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,UAAU,CAAC,CAuE3E"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * tableau.ts — Tableau Server "Guest" view CSV export, a keyless-first SLED
3
+ * transparency source (loop cycle 75, 2026-07-24 — Montana dark-state closure).
4
+ *
5
+ * WHAT IT ADDS: many US state/local governments publish contracts / vendor-payment
6
+ * / checkbook data on a Guest-enabled Tableau Server. A WORKSHEET view exports its
7
+ * FULL summary data as CSV at
8
+ * `https://{host}/t/{site}/views/{workbook}/{view}.csv?:embed=y`
9
+ * — anonymous, KEYLESS (no login, key, or session cookie required). This is the
10
+ * export the public Tableau UI itself offers ("Download ▸ Data"). First payload:
11
+ * Montana state **Contracts Awarded** (DOA), a live gov-con award register.
12
+ *
13
+ * ★ CURATED allowlist (SSRF core): each `base` is a FIXED, live-verified Tableau
14
+ * Server view URL (up to the view name; the tool appends `.csv?:embed=y`). The
15
+ * `view` enum in server.ts is built FROM the keys (single source of truth), and
16
+ * a post-construction hostname assertion (over https) guards the fetch
17
+ * (`redirect:"error"`). where/columns cannot alter the host.
18
+ *
19
+ * ★ HONESTY PILLARS:
20
+ * P1: the CSV is the COMPLETE view export — Tableau returns ALL summary rows in
21
+ * the view (there is NO server-side pagination on this endpoint), so
22
+ * totalAvailable = the parsed DATA-row count (the true total, NOT a page
23
+ * length); limit/offset page over it CLIENT-side. NOTE (disclosed every
24
+ * response): a Tableau Server MAY server-cap a very large summary export — the
25
+ * seeded views are live-verified COMPLETE (non-round counts), and a round-number
26
+ * count is flagged as a possible cap.
27
+ * P2: getText THROWS on 429 / 5xx / 404 / timeout — NEVER a fake empty. A view
28
+ * that is gated/renamed (a 200 sign-in HTML, or a dashboard-CONTAINER whose CSV
29
+ * export is empty) ⇒ schema_drift (a loud, honest failure — NEVER a silent 0).
30
+ * A worksheet that legitimately has a header but zero data rows ⇒ honest empty.
31
+ * P3: values are TRIMMED strings (surrounding whitespace removed; an empty field
32
+ * ⇒ null, never 0 or ""). The value CONTENT is preserved — amounts/dates are
33
+ * FORMATTED STRINGS (e.g. "$5,879,590.00"), parse client-side. Header trimmed.
34
+ * P4: a 200 body that is not CSV (HTML, or no header row) ⇒ schema_drift.
35
+ */
36
+ import { ToolErrorCarrier } from "./errors.js";
37
+ import { getText, driftError } from "./datasource.js";
38
+ import { num, str } from "./coerce.js";
39
+ import { parseCsv } from "./gov-domains.js";
40
+ import { withMeta } from "./meta.js";
41
+ export { num };
42
+ export const TABLEAU_VIEWS = [
43
+ {
44
+ key: "mt_contracts_awarded",
45
+ base: "https://tableau-ext.mt.gov/t/DOA/views/ContractsAwarded/ContractsAwarded",
46
+ label: "Montana DOA — Contracts Awarded",
47
+ note: "State of Montana contract/solicitation awards (columns: '$ Awarded', 'Award Date', 'Event Title', 'Event Type' (Invitation For Bid / Request for Proposal), 'Event#' (solicitation number), 'Montana Vendor' (Y/N; '?'=unknown), 'Vendor Name', 'Agency'). ~4,554 awards, April 2020–present. ★'$ Awarded' is a FORMATTED STRING (e.g. \" $1,878,796.10 \") — parse client-side. Source: transparency.mt.gov (Tableau Server Guest CSV).",
48
+ },
49
+ ];
50
+ const VIEW_BY_KEY = new Map(TABLEAU_VIEWS.map((v) => [v.key, v]));
51
+ const VALUE_NOTE = "Values are TRIMMED strings (surrounding whitespace removed; an empty field ⇒ null, never 0 or \"\"). The content is preserved — amounts/dates are FORMATTED STRINGS (e.g. \"$5,879,590.00\"), parse client-side.";
52
+ /** Build the `.csv?:embed=y` export URL + assert it stays on the allowlisted host. */
53
+ function csvUrl(v) {
54
+ const url = `${v.base}.csv?:embed=y`;
55
+ const allowedHost = new URL(v.base).hostname;
56
+ const built = new URL(url);
57
+ if (built.hostname !== allowedHost || built.protocol !== "https:") {
58
+ throw new ToolErrorCarrier({
59
+ kind: "invalid_input",
60
+ message: `Constructed Tableau URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted view host ${JSON.stringify(allowedHost)} over https — refusing to fetch (SSRF safety).`,
61
+ retryable: false,
62
+ upstreamEndpoint: `tableau:${v.key}`,
63
+ });
64
+ }
65
+ return url;
66
+ }
67
+ /**
68
+ * Fetch a curated Tableau Server Guest view's COMPLETE CSV export (keyless) and
69
+ * page over it client-side. `view` is an allowlist enum; `limit`/`offset` page
70
+ * the parsed rows. Returns { view, columns, rows:[{col:value…}] } + honest _meta
71
+ * (totalAvailable = the complete row count, NOT a page length).
72
+ */
73
+ export async function viewCsv(args) {
74
+ const v = VIEW_BY_KEY.get(args.view);
75
+ if (!v) {
76
+ throw new ToolErrorCarrier({
77
+ kind: "invalid_input",
78
+ message: `Unknown Tableau view ${JSON.stringify(args.view)}. Allowed: ${TABLEAU_VIEWS.map((s) => s.key).join(", ")}.`,
79
+ retryable: false,
80
+ });
81
+ }
82
+ const limit = args.limit ?? 50;
83
+ const offset = args.offset ?? 0;
84
+ // ── Fetch the full view CSV. getText THROWS on 429/5xx/404/timeout (P2). ──
85
+ const url = csvUrl(v);
86
+ const body = await getText(url, { label: `tableau:${v.key}`, redirect: "error", timeoutMs: 45_000 });
87
+ // P4: a non-CSV body (HTML sign-in / error page) ⇒ schema_drift, never parsed as empty.
88
+ const text = body.replace(/^/, ""); // strip a leading UTF-8 BOM if present
89
+ if (text.trim().length === 0 || /^\s*</.test(text)) {
90
+ throw driftError(`tableau:${v.key}`, "Tableau returned an empty or non-CSV (HTML) body at HTTP 200 — the view may be gated, renamed, or a dashboard container (not a worksheet). Schema drift — refusing to report a fake empty.");
91
+ }
92
+ const table = parseCsv(text);
93
+ // P4: the first row MUST be a header (≥1 named column). No rows ⇒ drift.
94
+ if (table.length === 0 || !Array.isArray(table[0]) || table[0].length === 0) {
95
+ throw driftError(`tableau:${v.key}`, "Tableau CSV has no header row — schema drift.");
96
+ }
97
+ const header = table[0].map((h) => str(h) ?? "");
98
+ const dataRows = table.slice(1);
99
+ const totalAvailable = dataRows.length; // P1: complete view export = true total
100
+ const pageRows = dataRows.slice(offset, offset + limit).map((r) => {
101
+ const obj = {};
102
+ for (let i = 0; i < header.length; i++)
103
+ obj[header[i] || `col${i}`] = str(r[i]);
104
+ return obj;
105
+ });
106
+ const returned = pageRows.length;
107
+ const hasMore = offset + returned < totalAvailable;
108
+ const nextOffset = hasMore ? offset + returned : null;
109
+ // P1 cap disclosure: a suspiciously round total may indicate a server export cap.
110
+ const roundCap = totalAvailable >= 1000 && totalAvailable % 1000 === 0;
111
+ const notes = [
112
+ `Source: ${v.label} (Tableau Server Guest CSV export, keyless). ${v.note}`,
113
+ "totalAvailable is the COMPLETE view export row count (Tableau returns all summary rows; there is no server-side pagination) — limit/offset page over the full set client-side.",
114
+ "Pagination order follows the Tableau view's OWN sort. Each call re-fetches the complete CSV and slices it; if the view lacks a stable sort, offsets across SEPARATE calls could shift — for a consistent snapshot of a large view, fetch it with a single large limit.",
115
+ VALUE_NOTE,
116
+ "FRESHNESS is set by the publisher: a Tableau export carries no refresh timestamp, and the view reflects whenever the publisher last refreshed it, which can lag by weeks. Check the newest value in the view's date columns before relying on recency.",
117
+ ];
118
+ if (roundCap)
119
+ notes.push(`NOTE: the row count (${totalAvailable}) is an exact multiple of 1000 — Tableau Server MAY have capped this summary export, so totalAvailable could be a lower bound. Treat with caution.`);
120
+ return withMeta({ view: v.key, columns: header, rows: pageRows }, {
121
+ source: `${new URL(v.base).hostname} via Tableau Server Guest CSV (keyless)`,
122
+ keylessMode: true,
123
+ returned,
124
+ totalAvailable,
125
+ filtersApplied: ["view"],
126
+ filtersDropped: [],
127
+ fieldsUnavailable: [],
128
+ pagination: { offset, limit, hasMore, nextOffset },
129
+ notes,
130
+ });
131
+ }
132
+ //# sourceMappingURL=tableau.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tableau.js","sourceRoot":"","sources":["../src/tableau.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cliwant/mcp-sam-gov",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "mcpName": "io.github.cliwant/mcp-sam-gov",
5
- "description": "Most comprehensive keyless MCP server for US federal + state/local (SLED) contracting + spending + regulation: SAM.gov, USAspending, Federal Register, eCFR, Grants.gov, plus SLED procurement bids (OpenGov, Bonfire, ArcGIS, Socrata 53 hosts). 150 tools, no API key, plug into Claude Desktop / Claude Code / Codex CLI / Cursor / Continue / Gemini CLI.",
5
+ "description": "Most comprehensive keyless-first MCP server for US federal + state/local (SLED) contracting + spending + regulation: SAM.gov, USAspending, Federal Register, eCFR, Grants.gov, plus SLED procurement bids (OpenGov, Bonfire, ArcGIS, Socrata 53 hosts). 152 tools (147 need no API key), plug into Claude Desktop / Claude Code / Codex CLI / Cursor / Continue / Gemini CLI.",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "model-context-protocol",
@@ -79,6 +79,17 @@ export const ARCGIS_SERVICES: readonly ArcgisService[] = [
79
79
  { key: "iowadot_public_bid_awards", base: "https://services.arcgis.com/8lRhdTsQyJpO52F1/arcgis/rest/services/Project_Scheduling_Public_Bid_Point_View/FeatureServer/0", label: "Iowa DOT — Public Bid / Project Scheduling", note: "Iowa Department of Transportation public bid & project scheduling (PROJECT_NUMBER/WORK_DESC/LETTING_FISCAL_YEAR/PROGRAM_ESTIMATE/CONTRACT_AWARDED/AWARDED/FINAL_CONTRACT/CONTRACTOR/CONTRACT_ID/STATUS). ~360." },
80
80
  { key: "okdot_cirb_contract_status", base: "https://services6.arcgis.com/RBtoEUQ2lmN0K3GY/arcgis/rest/services/CIRB_Project_Status/FeatureServer/0", label: "Oklahoma DOT — CIRB Contract Status", note: "Oklahoma Department of Transportation County Improvements for Roads & Bridges (CIRB) contract status (T_PROJECT/JP_DESCRIPTION/A_CONTRACT_AMOUNT/A_AMOUNT_EARNED/A_AMOUNT_PAID/D_LET_DATE/D_CONTRACT_AWARD_DATE/D_WORK_ORDER_DATE). ~780." },
81
81
  { key: "topeka_checkbook_aggregate", base: "https://services1.arcgis.com/EvtgI3PZ9PyCGJZS/arcgis/rest/services/Checkbook_Aggregate/FeatureServer/0", label: "City of Topeka KS — Open Checkbook (aggregate FY2015–2023)", note: "City of Topeka KS open checkbook, all years aggregated FY2015–2023 (fiscal_year/fiscal_period/department/program/fund/vendor_name/description/dollars). ~332k rows, ~323.6k with dollars>0." },
82
+ // ── North Dakota dark-state closure (loop cycle 74, 2026-07-24). NDDOT federal
83
+ // flex-funding AWARDS on Esri-cloud services1.arcgis.com (live-verified
84
+ // returnCountOnly + fields, no PII). ★ND's AUTHORITATIVE statewide checkbook
85
+ // (omb.nd.gov) and procurement (ndbuys.nd.gov, Ivalua) are keyless-UNREACHABLE
86
+ // (the 165.234.x state network refuses external connections — verified from
87
+ // two independent vantages) / CAPTCHA+SSO-gated → recorded structurally-blocked
88
+ // in TRIAGE; these DOT award layers are ND's best-available keyless proxy. ──
89
+ { key: "nddot_flex_setaside_road", base: "https://services1.arcgis.com/EDijJFsQQwgz8X53/arcgis/rest/services/Flex_Funding_Awarded_WFL1/FeatureServer/20", label: "North Dakota DOT — Flex Funding Awarded (Set-Aside, Road)", note: "NDDOT federal flex-funding ROAD awards, Set-Aside category (LPA_NAME=recipient local public agency, LPA_TYPE=County/Township/City, Total_Project_Cost, Flex_Funds_Awarded, Work_Type). ~33. ★$ amounts are FORMATTED STRINGS (e.g. \" $5,879,590.00 \") — parse client-side; recipients are LOCAL PUBLIC AGENCIES, not vendors. ND's authoritative checkbook/procurement is keyless-unreachable — this is a proxy." },
90
+ { key: "nddot_flex_partner_road", base: "https://services1.arcgis.com/EDijJFsQQwgz8X53/arcgis/rest/services/Flex_Funding_Awarded_WFL1/FeatureServer/22", label: "North Dakota DOT — Flex Funding Awarded (Partner-Allocated, Road)", note: "NDDOT federal flex-funding ROAD awards, Partner-Allocated category (LPA_NAME/LPA_TYPE/Total_Project_Cost/Flex_Funds_Awarded/Work_Type). ~22. ★$ amounts are FORMATTED STRINGS; recipients are LOCAL PUBLIC AGENCIES. Companion to nddot_flex_setaside_road. ND's authoritative checkbook/procurement is keyless-unreachable — this is a proxy." },
91
+ { key: "nddot_flex_setaside_bridge", base: "https://services1.arcgis.com/EDijJFsQQwgz8X53/arcgis/rest/services/Flex_Funding_Awarded_WFL1/FeatureServer/1", label: "North Dakota DOT — Flex Funding Awarded (Set-Aside, Bridge)", note: "NDDOT federal flex-funding BRIDGE awards, Set-Aside category (LPA_NAME/LPA_TYPE/Total_Project_Cost/Flex_Funds_Awarded/Work_Type). ~12. ★$ amounts are FORMATTED STRINGS; recipients are LOCAL PUBLIC AGENCIES. ND's authoritative checkbook/procurement is keyless-unreachable — this is a proxy." },
92
+ { key: "nddot_flex_partner_bridge", base: "https://services1.arcgis.com/EDijJFsQQwgz8X53/arcgis/rest/services/Flex_Funding_Awarded_WFL1/FeatureServer/21", label: "North Dakota DOT — Flex Funding Awarded (Partner-Allocated, Bridge)", note: "NDDOT federal flex-funding BRIDGE awards, Partner-Allocated category (LPA_NAME/LPA_TYPE/Total_Project_Cost/Flex_Funds_Awarded/Work_Type). ~1. ★$ amounts are FORMATTED STRINGS; recipients are LOCAL PUBLIC AGENCIES. ND's authoritative checkbook/procurement is keyless-unreachable — this is a proxy." },
82
93
  ] as const;
83
94
 
84
95
  const SERVICE_BY_KEY: ReadonlyMap<string, ArcgisService> = new Map(ARCGIS_SERVICES.map((s) => [s.key, s]));
package/src/bonfire.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * ★ NO keyless directory API: Bonfire's authoritative org list
12
12
  * (`GET common-production-api-global.bonfirehub.com/v1.0/organizations/external`)
13
13
  * is AUTH-GATED (a free vendor-account token) — OUT OF BOUNDS (we never sign in).
14
- * So this ships a CURATED, live-verified SEED directory (187 US orgs; §BONFIRE_
14
+ * So this ships a CURATED, live-verified SEED directory (186 US orgs; §BONFIRE_
15
15
  * ORGS) as `bonfire_list_organizations`, and documents the keyless RSS-probe
16
16
  * refresh method (no catch-all: `{slug}.bonfirehub.com/opportunities/rss` returns
17
17
  * 200 <rss> for a real org, a connection failure for a non-provisioned slug). The
@@ -53,7 +53,7 @@ const BONFIRE_SOURCE = (org: string) =>
53
53
  const BONFIRE_SEED_NOTE =
54
54
  "This directory is a CURATED, live-verified SEED (Bonfire has NO keyless org-list API; the authoritative list is auth-gated and out of bounds). Euna markets up to ~900 US orgs, so the seed is partial — probe `{slug}.bonfirehub.com/opportunities/rss` (200 <rss> = real org) to extend. Feed a result's `org` to bonfire_search_opportunities.";
55
55
 
56
- // ─── The curated 187-org US seed directory (live-verified 2026-07-19) ──
56
+ // ─── The curated 186-org US seed directory (live-verified 2026-07-19) ──
57
57
  // "slug|Entity|ST" — the slug is the RSS subdomain. Non-US (.ca / cayman / etc.)
58
58
  // deliberately excluded.
59
59
  const BONFIRE_SEED_RAW: readonly string[] = [
package/src/echo.ts CHANGED
@@ -28,8 +28,12 @@
28
28
  * any fetch (the path-injection guard).
29
29
  * (3) Every interpolated id is grammar-validated BEFORE use — `state` ∈ a frozen
30
30
  * US state/territory enum (also the silent-zero guard, below); `naics`
31
- * ^[0-9]{2,6}$ / `sic` ^[0-9]{2,4}$; `registryId` ^[0-9]{9,12}$ (FRS IDs are
32
- * 12 digits; all-digit is the security property); the UPSTREAM-supplied
31
+ * ^[0-9]{2,6}$ / `sic` ^[0-9]{2,4}$; `registryId` ^[A-Za-z0-9]{1,20}$ (FRS
32
+ * ids are 12 digits, but ECHO's own search rows also carry state/program ids
33
+ * like 'DCR000509282' and short ids like '9434', and get_dfr serves them —
34
+ * live-verified 2026-09-13; the ALPHANUMERIC charclass is the security
35
+ * property: no separator, space, '%' or newline can reach p_id, and an id
36
+ * ECHO does not know comes back "ID … is invalid" ⇒ not_found); the UPSTREAM-supplied
33
37
  * `qid` is validated ^[0-9]+$ BECAUSE it is external (echodata.epa.gov mints
34
38
  * it), before it is used in step 2; the internally-computed `pageno` is a
35
39
  * plain integer. `facilityName` (p_fn) is a free-text filter VALUE — encoded
@@ -122,10 +126,12 @@ const ECHO_SERVICES: ReadonlySet<string> = new Set([
122
126
  // ECHO does NOT validate filter VALUES: an unknown value silently returns
123
127
  // QueryRows:"0" (indistinguishable from a genuine-empty). So we validate
124
128
  // client-side: `state` against the enum below (surfaced by the Zod enum in
125
- // server.ts), naics/sic against a digit-length grammar, registryId all-digit.
129
+ // server.ts), naics/sic against a digit-length grammar, registryId alphanumeric.
126
130
  const NAICS_RE = /^[0-9]{2,6}$/;
127
131
  const SIC_RE = /^[0-9]{2,4}$/;
128
- const REGISTRY_ID_RE = /^[0-9]{9,12}$/;
132
+ // Alphanumeric, not all-digit: ECHO search rows return non-FRS ids (e.g. 'DCR000509282',
133
+ // '9434') that get_dfr accepts, so an all-digit 9–12 grammar broke search → report.
134
+ const REGISTRY_ID_RE = /^[A-Za-z0-9]{1,20}$/;
129
135
  // The UPSTREAM-supplied QueryID — validated BECAUSE it is external (echodata mints
130
136
  // it), before it is used to build the step-2 URL.
131
137
  const QID_RE = /^[0-9]+$/;
@@ -457,11 +463,11 @@ export async function searchFacilities(args: {
457
463
  export async function facilityReport(args: {
458
464
  registryId: string;
459
465
  }): Promise<MetaBundle> {
460
- // Belt-and-suspenders (behind the server's Zod ^[0-9]{9,12}$).
466
+ // Belt-and-suspenders (behind the server's Zod ^[A-Za-z0-9]{1,20}$).
461
467
  if (!REGISTRY_ID_RE.test(args.registryId)) {
462
468
  throw new ToolErrorCarrier({
463
469
  kind: "invalid_input",
464
- message: `Invalid registryId ${JSON.stringify(args.registryId)} — expected an all-digit FRS RegistryID (9–12 digits).`,
470
+ message: `Invalid registryId ${JSON.stringify(args.registryId)} — expected the RegistryID exactly as returned by echo_search_facilities (1–20 letters/digits, e.g. '110059768461' or 'DCR000509282').`,
465
471
  retryable: false,
466
472
  });
467
473
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * open-checkbook.ts — Socrata "Open Expenditures / Open Checkbook" row-level
3
+ * vendor-payment API, a keyless-first SLED source (loop cycle 76, 2026-07-24 —
4
+ * South Dakota dark-state closure, the LAST dark state).
5
+ *
6
+ * WHAT IT ADDS: some US governments run Socrata's "Open Expenditures" product,
7
+ * whose public dashboard fronts a KEYLESS app-proxy JSON API at
8
+ * `https://{host}/api/checkbook_data.json?year=…&{filters}&page=P&limit=L`
9
+ * → `{ data:[…row-level payments…], count, total_amount }`. First (and, as of
10
+ * this writing, only live-verified) portal: **South Dakota Open Checkbook**
11
+ * (740,980 vendor-payment rows, ~$8.41B, the ~3 most-recent fiscal years).
12
+ *
13
+ * ★ KEYLESS- vs-GATED HONESTY (load-bearing): the app-proxy above is anonymous
14
+ * and public. The UNDERLYING Socrata SODA dataset (7uwr-juaf on
15
+ * southdakota.data.socrata.com) is **403 login-gated** — this module NEVER
16
+ * touches it and NEVER presents it as reachable. We only call the public
17
+ * /api/checkbook_data.json surface the dashboard itself uses anonymously.
18
+ *
19
+ * ★ CURATED allowlist (SSRF core): each portal is a FIXED, live-verified host;
20
+ * the `portal` enum in server.ts is built FROM the keys. Host asserted before
21
+ * fetch (redirect:"error").
22
+ *
23
+ * ★ HONESTY PILLARS:
24
+ * P1: totalAvailable = the API's own `count` (the REAL filtered total — it
25
+ * matches the product's totals.json exactly; e.g. 740,980 unfiltered,
26
+ * 109,887 for org1=TRANSPORTATION), NEVER the page length.
27
+ * P2: getJson THROWS on 429/5xx/timeout — NEVER a fake empty. A bogus filter
28
+ * ⇒ honest count:0/empty; a deep offset past the end ⇒ returned:0 with the
29
+ * real count preserved (an honest tail, not an outage).
30
+ * P3: `amount` ⇒ number|null (a real $0 is 0, an absent value is null, never a
31
+ * fabricated 0); all other fields via str (null-never-empty). Dates verbatim.
32
+ * P4: a body that is not `{data:[…], count:<number>}` ⇒ schema_drift.
33
+ * ★ Coverage disclosure (P5): only the ~3 most-recent fiscal years are exposed
34
+ * by the product (NOT full history) — disclosed every response.
35
+ */
36
+
37
+ import { ToolErrorCarrier } from "./errors.js";
38
+ import { getJson, driftError } from "./datasource.js";
39
+ import { num, str } from "./coerce.js";
40
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
41
+
42
+ export { num };
43
+
44
+ // ─── Curated portal allowlist (SSRF core) — LIVE-VERIFIED 2026-07-24 ──
45
+ export type OpenCheckbookPortal = { key: string; host: string; label: string; note: string };
46
+ export const OPEN_CHECKBOOK_PORTALS: readonly OpenCheckbookPortal[] = [
47
+ {
48
+ key: "sd",
49
+ host: "southdakota.spending.socrata.com",
50
+ label: "South Dakota — Open Checkbook",
51
+ note: "State of South Dakota vendor-payment checkbook (row fields: expense_category, description, fund, payment_date, vendor, org1=department, amount, custom_checkbook_field7=invoice ref, payment_id). ~740,980 rows / ~$8.41B across the ~3 most-recent fiscal years (NOT full history). The underlying Socrata SODA dataset is login-gated; this public app-proxy is the keyless door.",
52
+ },
53
+ ] as const;
54
+
55
+ const PORTAL_BY_KEY: ReadonlyMap<string, OpenCheckbookPortal> = new Map(OPEN_CHECKBOOK_PORTALS.map((p) => [p.key, p]));
56
+
57
+ // Sort fields the product supports (validated — an SSRF/injection + silent-noop guard).
58
+ const SORT_FIELDS = new Set(["amount", "payment_date", "vendor", "org1", "expense_category"]);
59
+
60
+ // ─── SSRF-guarded fetch (fixed allowlist host + assertion) ──
61
+ async function getCheckbook(portal: OpenCheckbookPortal, params: URLSearchParams): Promise<unknown> {
62
+ const url = `https://${portal.host}/api/checkbook_data.json?${params.toString()}`;
63
+ const built = new URL(url);
64
+ if (built.hostname !== portal.host || built.protocol !== "https:") {
65
+ throw new ToolErrorCarrier({
66
+ kind: "invalid_input",
67
+ message: `Constructed Open-Checkbook URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted portal host ${JSON.stringify(portal.host)} over https — refusing to fetch (SSRF safety).`,
68
+ retryable: false,
69
+ upstreamEndpoint: `open-checkbook:${portal.key}`,
70
+ });
71
+ }
72
+ return getJson(url, { label: `open-checkbook:${portal.key}`, redirect: "error" });
73
+ }
74
+
75
+ // ─── Tool: open_checkbook_search ──────────────────────────────────
76
+ export type OpenCheckbookSearchArgs = {
77
+ portal: string;
78
+ year?: string;
79
+ vendor?: string;
80
+ org?: string;
81
+ expenseCategory?: string;
82
+ sortBy?: string;
83
+ sortOrder?: string;
84
+ limit?: number;
85
+ offset?: number;
86
+ };
87
+
88
+ /**
89
+ * Row-level vendor-payment search over a curated Socrata Open-Expenditures
90
+ * checkbook portal (keyless). `portal` is an allowlist enum; `year`/`vendor`/
91
+ * `org`/`expenseCategory` are EXACT-match filters; `sortBy`/`sortOrder` sort;
92
+ * `limit`/`offset` page. Returns { portal, rows:[…] } + honest _meta
93
+ * (totalAvailable = the API's real count, NOT a page length).
94
+ */
95
+ export async function openCheckbookSearch(args: OpenCheckbookSearchArgs): Promise<MetaBundle> {
96
+ const portal = PORTAL_BY_KEY.get(args.portal);
97
+ if (!portal) {
98
+ throw new ToolErrorCarrier({
99
+ kind: "invalid_input",
100
+ message: `Unknown Open-Checkbook portal ${JSON.stringify(args.portal)}. Allowed: ${OPEN_CHECKBOOK_PORTALS.map((p) => p.key).join(", ")}.`,
101
+ retryable: false,
102
+ });
103
+ }
104
+ const limit = args.limit ?? 25;
105
+ const offset = args.offset ?? 0;
106
+ // The product paginates by 1-based `page` + `limit`. Map offset→page and snap
107
+ // offset to the page boundary (disclosing the served offset when it differs).
108
+ const page = Math.floor(offset / limit) + 1;
109
+ const servedOffset = (page - 1) * limit;
110
+
111
+ const filtersApplied: string[] = ["portal"];
112
+ const params = new URLSearchParams();
113
+ params.set("year", args.year && args.year.trim() ? args.year.trim() : "All Years");
114
+ if (args.year && args.year.trim()) filtersApplied.push("year");
115
+ if (args.vendor && args.vendor.trim()) { params.set("vendor", args.vendor.trim()); filtersApplied.push("vendor"); }
116
+ if (args.org && args.org.trim()) { params.set("org1", args.org.trim()); filtersApplied.push("org"); }
117
+ if (args.expenseCategory && args.expenseCategory.trim()) { params.set("expense_category", args.expenseCategory.trim()); filtersApplied.push("expenseCategory"); }
118
+ if (args.sortBy && args.sortBy.trim()) {
119
+ const sf = args.sortBy.trim();
120
+ if (!SORT_FIELDS.has(sf)) {
121
+ throw new ToolErrorCarrier({
122
+ kind: "invalid_input",
123
+ message: `Invalid sortBy ${JSON.stringify(sf)}. Allowed: ${[...SORT_FIELDS].join(", ")}.`,
124
+ retryable: false,
125
+ });
126
+ }
127
+ params.set("sort_field", sf);
128
+ params.set("sort_order", args.sortOrder === "asc" ? "asc" : "desc");
129
+ filtersApplied.push("sortBy");
130
+ }
131
+ params.set("page", String(page));
132
+ params.set("limit", String(limit));
133
+
134
+ const body = await getCheckbook(portal, params);
135
+ const b = (body ?? {}) as { data?: unknown; count?: unknown; total_amount?: unknown };
136
+ // P4: the shape MUST be { data:[…], count:<number> }.
137
+ if (!Array.isArray(b.data)) throw driftError(`open-checkbook:${portal.key}`, "Open-Checkbook shape drift — response.data must be an array.");
138
+ const totalAvailable = num(b.count);
139
+ if (totalAvailable === null) throw driftError(`open-checkbook:${portal.key}`, "Open-Checkbook shape drift — response.count must be a number.");
140
+
141
+ const rows = (b.data as unknown[]).map((r) => {
142
+ const o = (r ?? {}) as Record<string, unknown>;
143
+ return {
144
+ vendor: str(o.vendor),
145
+ amount: num(o.amount), // P3: a real $0 is 0; absent ⇒ null (never a fabricated 0)
146
+ payment_date: str(o.payment_date),
147
+ org1: str(o.org1),
148
+ expense_category: str(o.expense_category),
149
+ description: str(o.description),
150
+ fund: str(o.fund),
151
+ invoice: str(o.custom_checkbook_field7),
152
+ payment_id: str(o.payment_id),
153
+ };
154
+ });
155
+ const returned = rows.length;
156
+ const hasMore = servedOffset + returned < totalAvailable;
157
+ const nextOffset = hasMore ? servedOffset + returned : null;
158
+
159
+ const notes: string[] = [
160
+ `Source: ${portal.label} (Socrata Open Expenditures app-proxy /api/checkbook_data.json, keyless). ${portal.note}`,
161
+ "totalAvailable = the API's exact match count (matches the product's totals.json), NOT the page length.",
162
+ "Filters (year/vendor/org/expenseCategory) are EXACT-match — a partial/misspelled value returns an honest count:0, not an error. amount is number|null (a real $0 is 0, an absent value is null, never a fabricated 0).",
163
+ "COVERAGE: only the ~3 most-recent fiscal years are exposed by this product — this is NOT the state's full payment history.",
164
+ "FRESHNESS is set by the publisher: the portal says it refreshes each payment cycle, but refreshes can lag by weeks. To check recency, sort by payment_date (sortBy='payment_date', sortOrder='desc') and read the newest date.",
165
+ ];
166
+ if (servedOffset !== offset)
167
+ notes.push(`offset ${offset} was snapped to ${servedOffset} (the product paginates by fixed page×limit); pass an offset that is a multiple of limit to avoid snapping.`);
168
+
169
+ return withMeta(
170
+ { portal: portal.key, rows },
171
+ {
172
+ source: `${portal.host} via Socrata Open Expenditures (keyless app-proxy)`,
173
+ keylessMode: true,
174
+ returned,
175
+ totalAvailable,
176
+ filtersApplied,
177
+ filtersDropped: [],
178
+ fieldsUnavailable: [],
179
+ pagination: { offset: servedOffset, limit, hasMore, nextOffset },
180
+ notes,
181
+ } satisfies Partial<ResponseMeta>,
182
+ );
183
+ }