@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.
- package/README.ja.md +17 -13
- package/README.ko.md +17 -13
- package/README.md +21 -16
- package/dist/arcgis-feature.d.ts.map +1 -1
- package/dist/arcgis-feature.js +11 -0
- package/dist/arcgis-feature.js.map +1 -1
- package/dist/bonfire.d.ts +1 -1
- package/dist/bonfire.js +2 -2
- package/dist/echo.d.ts +6 -2
- package/dist/echo.d.ts.map +1 -1
- package/dist/echo.js +12 -6
- package/dist/echo.js.map +1 -1
- package/dist/open-checkbook.d.ts +65 -0
- package/dist/open-checkbook.d.ts.map +1 -0
- package/dist/open-checkbook.js +166 -0
- package/dist/open-checkbook.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +57 -9
- package/dist/server.js.map +1 -1
- package/dist/tableau.d.ts +58 -0
- package/dist/tableau.d.ts.map +1 -0
- package/dist/tableau.js +132 -0
- package/dist/tableau.js.map +1 -0
- package/package.json +2 -2
- package/src/arcgis-feature.ts +11 -0
- package/src/bonfire.ts +2 -2
- package/src/echo.ts +12 -6
- package/src/open-checkbook.ts +183 -0
- package/src/server.ts +61 -9
- package/src/tableau.ts +159 -0
|
@@ -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"}
|
package/dist/tableau.js
ADDED
|
@@ -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.
|
|
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).
|
|
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",
|
package/src/arcgis-feature.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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` ^[
|
|
32
|
-
* 12 digits
|
|
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
|
|
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
|
-
|
|
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 ^[
|
|
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
|
|
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
|
+
}
|