@cliwant/mcp-sam-gov 1.12.0 → 1.13.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/server.ts CHANGED
@@ -52,6 +52,8 @@ import * as arcgisHub from "./arcgis-hub.js";
52
52
  import * as opengov from "./opengov.js";
53
53
  import * as bonfire from "./bonfire.js";
54
54
  import * as arcgisFeature from "./arcgis-feature.js";
55
+ import * as tableau from "./tableau.js";
56
+ import * as openCheckbook from "./open-checkbook.js";
55
57
  import * as govinfo from "./govinfo.js";
56
58
  import * as fpds from "./fpds.js";
57
59
  import * as nih from "./nih.js";
@@ -106,7 +108,7 @@ import { realpathSync } from "node:fs";
106
108
  const SERVER_NAME = "mcp-sam-gov";
107
109
  // Kept in lockstep with package.json / manifest.json / server.json.
108
110
  // Keep in sync with package.json "version" (asserted at release; see CHANGELOG).
109
- const SERVER_VERSION = "1.12.0";
111
+ const SERVER_VERSION = "1.13.0";
110
112
 
111
113
  // ─── Tool input schemas (Zod) ────────────────────────────────────
112
114
 
@@ -2756,9 +2758,9 @@ const EchoSearchFacilitiesInput = z.object({
2756
2758
  const EchoFacilityReportInput = z.object({
2757
2759
  registryId: z
2758
2760
  .string()
2759
- .regex(/^[0-9]{9,12}$/)
2761
+ .regex(/^[A-Za-z0-9]{1,20}$/)
2760
2762
  .describe(
2761
- "The facility's FRS RegistryID (from echo_search_facilities rows' RegistryID) — an all-digit id, 9–12 digits (e.g. '110059768461'). A bad/unknown id ⇒ not_found (never a fabricated report).",
2763
+ "The facility's RegistryID exactly as returned in echo_search_facilities rows — usually a 12-digit FRS id (e.g. '110059768461'), but ECHO also returns state/program ids (e.g. 'DCR000509282') and short ids (e.g. '9434'), which the report accepts. 1–20 letters/digits. A bad/unknown id ⇒ not_found (never a fabricated report).",
2762
2764
  ),
2763
2765
  });
2764
2766
 
@@ -3042,7 +3044,7 @@ const OpengovSearchSolicitationsInput = z.object({
3042
3044
  // ─── Bonfire (Euna) — keyless per-org open-opportunity RSS (SLED bids) ─
3043
3045
  // SLED bid campaign. Thousands of US state/local govs on Bonfire expose a keyless
3044
3046
  // RSS of open opportunities at {org}.bonfirehub.com/opportunities/rss. Ships a
3045
- // curated 187-org seed directory. Fixed-suffix SSRF. org = charclass slug.
3047
+ // curated 186-org seed directory. Fixed-suffix SSRF. org = charclass slug.
3046
3048
  const BonfireListOrganizationsInput = z.object({
3047
3049
  state: z.string().length(2).optional().describe("2-letter US state filter (client-side), e.g. 'TX', 'CA'. Optional."),
3048
3050
  query: z.string().min(1).max(120).optional().describe("Case-insensitive name substring filter (client-side), e.g. 'county', 'ISD'. Optional."),
@@ -3061,6 +3063,34 @@ const BonfireSearchOpportunitiesInput = z.object({
3061
3063
  offset: z.number().int().min(0).default(0).describe("0-based offset; page with _meta.pagination.nextOffset. totalAvailable = the exact open-opportunity count."),
3062
3064
  });
3063
3065
 
3066
+ // ─── Socrata Open Expenditures checkbook (curated portal allowlist — SLED) ─
3067
+ // Row-level vendor-payment search over the keyless /api/checkbook_data.json
3068
+ // app-proxy (the underlying SODA dataset is login-gated — never touched).
3069
+ const OpenCheckbookSearchInput = z.object({
3070
+ portal: z
3071
+ .enum(openCheckbook.OPEN_CHECKBOOK_PORTALS.map((p) => p.key) as [string, ...string[]])
3072
+ .describe("The curated Open-Checkbook portal (SSRF allowlist enum). 'sd' = State of South Dakota Open Checkbook (~740,980 vendor payments, ~$8.41B, ~3 most-recent fiscal years)."),
3073
+ year: z.string().min(1).max(40).optional().describe("Fiscal-year filter (EXACT match), e.g. '2025'. Default 'All Years' = the exposed ~3-year window (NOT full history)."),
3074
+ vendor: z.string().min(1).max(200).optional().describe("Vendor name filter (EXACT match, e.g. 'US BANK NA' → 917). A partial/misspelled value returns an honest count:0."),
3075
+ org: z.string().min(1).max(200).optional().describe("Department filter (org1, EXACT match, e.g. 'TRANSPORTATION' → 109,887)."),
3076
+ expenseCategory: z.string().min(1).max(200).optional().describe("Expense-category filter (EXACT match, e.g. 'CONTRACTUAL SERVICES')."),
3077
+ sortBy: z.enum(["amount", "payment_date", "vendor", "org1", "expense_category"]).optional().describe("Sort field. Pair with sortOrder."),
3078
+ sortOrder: z.enum(["asc", "desc"]).optional().describe("Sort direction (default desc when sortBy is set)."),
3079
+ limit: z.number().int().min(1).max(1000).default(25).describe("Rows per page, 1..1000, default 25."),
3080
+ offset: z.number().int().min(0).default(0).describe("0-based offset (snapped to a page×limit boundary; the served offset is disclosed). totalAvailable = the real match count, NOT a page length."),
3081
+ });
3082
+
3083
+ // ─── Tableau Server Guest view CSV (curated view allowlist — SLED transparency) ─
3084
+ // Fetch a Guest-enabled Tableau Server WORKSHEET view's COMPLETE CSV export
3085
+ // (keyless) and page over it client-side. `view` is an enum (SSRF core).
3086
+ const TableauViewCsvInput = z.object({
3087
+ view: z
3088
+ .enum(tableau.TABLEAU_VIEWS.map((v) => v.key) as [string, ...string[]])
3089
+ .describe("The curated Tableau Server Guest view (SSRF allowlist enum). 'mt_contracts_awarded' = State of Montana (DOA) Contracts Awarded (~4,554 award records: $ Awarded, Award Date, Event Type IFB/RFP, Event# solicitation, Vendor Name, Agency)."),
3090
+ limit: z.number().int().min(1).max(1000).default(50).describe("Rows per page, 1..1000, default 50. The CSV is the complete view export; this pages over it client-side."),
3091
+ offset: z.number().int().min(0).default(0).describe("0-based offset; page with _meta.pagination.nextOffset. totalAvailable = the complete export row count (NOT a page length)."),
3092
+ });
3093
+
3064
3094
  // ─── ArcGIS REST feature query (curated service allowlist — SLED bids/GIS) ─
3065
3095
  // Generic ArcGIS FeatureServer/MapServer layer query over a curated allowlist.
3066
3096
  // First payload: DC OCP PASS procurement layers (live solicitations/contracts/PO/
@@ -3068,7 +3098,7 @@ const BonfireSearchOpportunitiesInput = z.object({
3068
3098
  const ArcgisFeatureQueryInput = z.object({
3069
3099
  service: z
3070
3100
  .enum(arcgisFeature.ARCGIS_SERVICES.map((s) => s.key) as [string, ...string[]])
3071
- .describe("The curated ArcGIS layer (SSRF allowlist enum). DC OCP PASS: 'dc_pass_solicitations' (live solicitations ~25k), 'dc_pass_contracts', 'dc_pass_purchase_orders', 'dc_pass_payments'. Other US local govs: 'asheville_purchase_orders'/'asheville_po_summary' (Asheville NC), 'bellevue_vendor_payments'/'bellevue_awarded_contracts' (Bellevue WA), 'miamidade_purchase_orders_2025'/'miamidade_purchase_orders_2017' (Miami-Dade FL, current/2017), 'suffolk_county_ny_contracts_2018' (Suffolk County NY), 'matsu_borough_ak_checkbook' (Matanuska-Susitna Borough AK), 'lasvegas_checkbook' (Las Vegas NV ~373k), 'baltimore_checkbook' (Baltimore City MD ~367k), 'naperville_vendor_payments' (Naperville IL ~127k), 'worcester_ma_checkbook_fy25' (Worcester MA FY25), 'lasvegas_purchasing_contracts' (Las Vegas NV contract register), 'txdot_construction_projects' (Texas DOT, awarded construction company ~85k), 'akdot_construction_awards'/'akdot_aashtoware_proposals' (Alaska DOT&PF bid awards/proposals), 'iowadot_public_bid_awards' (Iowa DOT public bid), 'okdot_cirb_contract_status' (Oklahoma DOT CIRB contract status), 'topeka_checkbook_aggregate' (Topeka KS checkbook FY2015–2023 ~332k). 23 curated services (state DOT bid/award registers: TX/AK/IA/OK + municipal checkbooks/contracts)."),
3101
+ .describe("The curated ArcGIS layer (SSRF allowlist enum). DC OCP PASS: 'dc_pass_solicitations' (live solicitations ~25k), 'dc_pass_contracts', 'dc_pass_purchase_orders', 'dc_pass_payments'. Other US local govs: 'asheville_purchase_orders'/'asheville_po_summary' (Asheville NC), 'bellevue_vendor_payments'/'bellevue_awarded_contracts' (Bellevue WA), 'miamidade_purchase_orders_2025'/'miamidade_purchase_orders_2017' (Miami-Dade FL, current/2017), 'suffolk_county_ny_contracts_2018' (Suffolk County NY), 'matsu_borough_ak_checkbook' (Matanuska-Susitna Borough AK), 'lasvegas_checkbook' (Las Vegas NV ~373k), 'baltimore_checkbook' (Baltimore City MD ~367k), 'naperville_vendor_payments' (Naperville IL ~127k), 'worcester_ma_checkbook_fy25' (Worcester MA FY25), 'lasvegas_purchasing_contracts' (Las Vegas NV contract register), 'txdot_construction_projects' (Texas DOT, awarded construction company ~85k), 'akdot_construction_awards'/'akdot_aashtoware_proposals' (Alaska DOT&PF bid awards/proposals), 'iowadot_public_bid_awards' (Iowa DOT public bid), 'okdot_cirb_contract_status' (Oklahoma DOT CIRB contract status), 'topeka_checkbook_aggregate' (Topeka KS checkbook FY2015–2023 ~332k), 'nddot_flex_setaside_road'/'nddot_flex_partner_road'/'nddot_flex_setaside_bridge'/'nddot_flex_partner_bridge' (North Dakota DOT federal flex-funding awards to local public agencies — counties/townships/cities, NOT vendor contracts; a proxy because ND's checkbook/procurement portal is not keyless-reachable). 27 curated services (state DOT bid/award registers: TX/AK/IA/OK + ND DOT flex-funding awards + municipal checkbooks/contracts)."),
3072
3102
  where: z
3073
3103
  .string()
3074
3104
  .min(1)
@@ -6089,7 +6119,7 @@ export const TOOLS: ToolDef[] = [
6089
6119
  defineTool({
6090
6120
  name: "echo_facility_report",
6091
6121
  description:
6092
- "Fetch the EPA ECHO Detailed Facility Report (DFR) for ONE facility by its FRS RegistryID (keyless) — the per-facility compliance / enforcement / inspection / permit deep-dive for competitor or acquisition-target due diligence. Input `registryId` (all-digit FRS id, 9–12 digits, from echo_search_facilities rows). Returns { registryId, report:{…verbatim compliance/enforcement/permit detail…} } + single-record _meta (complete:true, no pagination). A bad/unknown RegistryID ⇒ not_found (never a fabricated report).",
6122
+ "Fetch the EPA ECHO Detailed Facility Report (DFR) for ONE facility by its FRS RegistryID (keyless) — the per-facility compliance / enforcement / inspection / permit deep-dive for competitor or acquisition-target due diligence. Input `registryId` (the RegistryID exactly as returned by echo_search_facilities rows — usually a 12-digit FRS id, but ECHO also returns state/program ids like 'DCR000509282' and short ids like '9434', all accepted; 1–20 letters/digits). Returns { registryId, report:{…verbatim compliance/enforcement/permit detail…} } + single-record _meta (complete:true, no pagination). A bad/unknown RegistryID ⇒ not_found (never a fabricated report).",
6093
6123
  inputSchema: EchoFacilityReportInput,
6094
6124
  handler: (input) => echo.facilityReport(input),
6095
6125
  }),
@@ -6194,13 +6224,13 @@ export const TOOLS: ToolDef[] = [
6194
6224
  }),
6195
6225
  // ━━━ Bonfire (Euna) — keyless per-org open-opportunity RSS (SLED bids) ━━━
6196
6226
  // SLED bid campaign. Thousands of US state/local govs on Bonfire expose a keyless
6197
- // RSS of open opportunities. Ships a curated 187-org live-verified seed directory
6227
+ // RSS of open opportunities. Ships a curated 186-org live-verified seed directory
6198
6228
  // (Bonfire's authoritative org API is auth-gated → out of bounds). Fixed-suffix
6199
6229
  // SSRF (.bonfirehub.com). RSS = the complete open set (totalAvailable honest).
6200
6230
  defineTool({
6201
6231
  name: "bonfire_list_organizations",
6202
6232
  description:
6203
- "List US governments on the Bonfire (Euna) eProcurement platform — the directory for bonfire_search_opportunities (keyless). Bonfire hosts thousands of US state/local governments' open-bid portals, each with a keyless RSS feed. Filter the curated seed by `state` (2-letter) / `query` (case-insensitive name substring); `limit`(1..200)/`offset`. Output: { organizations:[{ org, name, state }] }. Feed a result's `org` to bonfire_search_opportunities. ★HONESTY: this is a CURATED, live-verified SEED of 187 US orgs — Bonfire has NO keyless org-list API (its authoritative directory is auth-gated, out of bounds), and Euna markets up to ~900 US orgs, so the seed is PARTIAL (disclosed in _meta); probe `{slug}.bonfirehub.com/opportunities/rss` to extend. totalAvailable = the exact filtered seed count.",
6233
+ "List US governments on the Bonfire (Euna) eProcurement platform — the directory for bonfire_search_opportunities (keyless). Bonfire hosts thousands of US state/local governments' open-bid portals, each with a keyless RSS feed. Filter the curated seed by `state` (2-letter) / `query` (case-insensitive name substring); `limit`(1..200)/`offset`. Output: { organizations:[{ org, name, state }] }. Feed a result's `org` to bonfire_search_opportunities. ★HONESTY: this is a CURATED, live-verified SEED of 186 US orgs — Bonfire has NO keyless org-list API (its authoritative directory is auth-gated, out of bounds), and Euna markets up to ~900 US orgs, so the seed is PARTIAL (disclosed in _meta); probe `{slug}.bonfirehub.com/opportunities/rss` to extend. totalAvailable = the exact filtered seed count.",
6204
6234
  inputSchema: BonfireListOrganizationsInput,
6205
6235
  handler: (input) => bonfire.listOrganizations(input),
6206
6236
  }),
@@ -6222,6 +6252,28 @@ export const TOOLS: ToolDef[] = [
6222
6252
  inputSchema: ArcgisFeatureQueryInput,
6223
6253
  handler: (input) => arcgisFeature.featureQuery(input),
6224
6254
  }),
6255
+ // ─── Tableau Server Guest view CSV export (curated allowlist) — SLED transparency ─
6256
+ // Many US state/local govs publish contracts/vendor-payment/checkbook data on a
6257
+ // Guest-enabled Tableau Server; a worksheet view exports its full data as CSV at
6258
+ // {host}/t/{site}/views/{wb}/{view}.csv?:embed=y (keyless). First payload: Montana
6259
+ // Contracts Awarded — the ND-sibling in the dark-state closure campaign.
6260
+ defineTool({
6261
+ name: "tableau_view_csv",
6262
+ description:
6263
+ "Fetch a curated US-government **Tableau Server Guest** view's COMPLETE CSV export (keyless) and page over it — a SLED transparency source (many state/local govs publish contracts / vendor-payment / checkbook data on a Guest-enabled Tableau Server; a worksheet view exports as CSV at `{host}/t/{site}/views/{workbook}/{view}.csv?:embed=y`, no login/key/cookie). First payload: `mt_contracts_awarded` = **State of Montana (DOA) Contracts Awarded** (~4,554 award records: '$ Awarded', 'Award Date', 'Event Type' (Invitation For Bid / RFP), 'Event#' solicitation number, 'Vendor Name', 'Agency'). Inputs: `view` (the allowlist ENUM — SSRF core, never a free host), `limit`(1..1000)/`offset`. Returns { view, columns:[…], rows:[{col:value…}] } + honest _meta. HONESTY: the CSV is the COMPLETE view export (Tableau returns ALL summary rows — NO server pagination), so totalAvailable = the true row count (NEVER a page length); limit/offset page it client-side; a round-number total is flagged as a possible Tableau export cap. Values are TRIMMED strings (an empty field ⇒ null, never 0/\"\"); the content is preserved — amounts like \"$5,879,590.00\" are FORMATTED STRINGS, parse client-side. A 429/5xx/404/timeout THROWS; a gated/renamed view (200 sign-in HTML or an empty dashboard-container export) ⇒ schema_drift (a loud failure, NEVER a fake empty); a worksheet with a header but 0 data rows ⇒ honest empty. SSRF: fixed allowlist base + hostname assertion + redirect:error.",
6264
+ inputSchema: TableauViewCsvInput,
6265
+ handler: (input) => tableau.viewCsv(input),
6266
+ }),
6267
+ // ─── Socrata Open Expenditures checkbook (curated portal allowlist) — SLED ─
6268
+ // Row-level vendor payments via the keyless /api/checkbook_data.json app-proxy.
6269
+ // First portal: South Dakota — the LAST dark-state closure (presence → 100%).
6270
+ defineTool({
6271
+ name: "open_checkbook_search",
6272
+ description:
6273
+ "Row-level vendor-payment search over a curated US-government **Socrata Open Expenditures** checkbook portal (keyless) — a SLED spending source. Some govs run Socrata's 'Open Expenditures/Open Checkbook' product, whose public dashboard fronts a keyless app-proxy at `{host}/api/checkbook_data.json`. First portal: `sd` = **State of South Dakota Open Checkbook** (~740,980 vendor-payment rows, ~$8.41B, the ~3 most-recent fiscal years). Inputs: `portal` (allowlist ENUM — SSRF core), `year`/`vendor`/`org`/`expenseCategory` (EXACT-match filters), `sortBy`/`sortOrder`, `limit`(1..1000)/`offset`. Returns { portal, rows:[{vendor, amount, payment_date, org1, expense_category, description, fund, invoice, payment_id}] } + honest _meta. HONESTY: totalAvailable = the API's own `count` (the REAL filtered total — matches the product's totals.json, e.g. 740,980 unfiltered / 109,887 for org=TRANSPORTATION — NEVER a page length); `amount` = number|null (a real $0 is 0, an absent value is null, never a fabricated 0); an EXACT-match filter miss ⇒ honest count:0; a deep offset past the end ⇒ returned:0 with the real count preserved; a 429/5xx/timeout THROWS; a non-`{data:[],count}` body ⇒ schema_drift. ★Only the ~3 most-recent fiscal years are exposed (NOT full history — disclosed). ★The underlying Socrata SODA dataset is login-gated and is NEVER touched — only the public app-proxy the dashboard itself uses. SSRF: fixed allowlist host + assertion + redirect:error.",
6274
+ inputSchema: OpenCheckbookSearchInput,
6275
+ handler: (input) => openCheckbook.openCheckbookSearch(input),
6276
+ }),
6225
6277
  // ━━━ GovInfo (api.govinfo.gov) — the api.data.gov keyed trio's 3rd API (3) ━━━ ADR-0010
6226
6278
  // GPO-authoritative bulk publications (BILLS/PLAW/USCODE/CREC/CFR-FR editions/
6227
6279
  // BUDGET/GAOREPORTS) with PDF/XML/MODS downloads + provenance. 2nd consumer of the
@@ -6823,7 +6875,7 @@ const TOOL_SOURCE_LABELS: Record<string, string> = {
6823
6875
  clinicaltrials: "ClinicalTrials.gov", bls: "BLS", socrata: "Socrata", opengov: "OpenGov",
6824
6876
  nsf: "NSF", nonprofit: "Nonprofit", nhtsa: "NHTSA", gsa: "GSA", grants: "Grants.gov",
6825
6877
  fred: "FRED", fac: "FAC", echo: "EPA ECHO", dol: "DOL", congress: "Congress.gov",
6826
- ckan: "data.gov", bonfire: "Bonfire", arcgis: "ArcGIS", sba: "SBA", ofac: "OFAC",
6878
+ ckan: "data.gov", bonfire: "Bonfire", arcgis: "ArcGIS", tableau: "Tableau", open: "Open Checkbook", sba: "SBA", ofac: "OFAC",
6827
6879
  nws: "NWS", nppes: "NPPES", nist: "NIST", nih: "NIH", lda: "Senate LDA", hts: "USITC HTS",
6828
6880
  bea: "BEA", cbp: "CBP", cpsc: "CPSC", nvd: "NVD", courtlistener: "CourtListener",
6829
6881
  fpds: "FPDS", gao: "GAO", epa: "EPA", nsn: "NSN",
package/src/tableau.ts ADDED
@@ -0,0 +1,159 @@
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
+
37
+ import { ToolErrorCarrier } from "./errors.js";
38
+ import { getText, driftError } from "./datasource.js";
39
+ import { num, str } from "./coerce.js";
40
+ import { parseCsv } from "./gov-domains.js";
41
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
42
+
43
+ export { num };
44
+
45
+ // ─── Curated view allowlist (SSRF core) — LIVE-VERIFIED 2026-07-24 ──
46
+ // Each `base` is the Tableau Server view URL WITHOUT extension; the tool appends
47
+ // `.csv?:embed=y`. All keyless (anonymous, empty-cookie 200 verified).
48
+ export type TableauView = { key: string; base: string; label: string; note: string };
49
+ export const TABLEAU_VIEWS: readonly TableauView[] = [
50
+ {
51
+ key: "mt_contracts_awarded",
52
+ base: "https://tableau-ext.mt.gov/t/DOA/views/ContractsAwarded/ContractsAwarded",
53
+ label: "Montana DOA — Contracts Awarded",
54
+ 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).",
55
+ },
56
+ ] as const;
57
+
58
+ const VIEW_BY_KEY: ReadonlyMap<string, TableauView> = new Map(TABLEAU_VIEWS.map((v) => [v.key, v]));
59
+
60
+ const VALUE_NOTE =
61
+ "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.";
62
+
63
+ /** Build the `.csv?:embed=y` export URL + assert it stays on the allowlisted host. */
64
+ function csvUrl(v: TableauView): string {
65
+ const url = `${v.base}.csv?:embed=y`;
66
+ const allowedHost = new URL(v.base).hostname;
67
+ const built = new URL(url);
68
+ if (built.hostname !== allowedHost || built.protocol !== "https:") {
69
+ throw new ToolErrorCarrier({
70
+ kind: "invalid_input",
71
+ 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).`,
72
+ retryable: false,
73
+ upstreamEndpoint: `tableau:${v.key}`,
74
+ });
75
+ }
76
+ return url;
77
+ }
78
+
79
+ // ─── Tool: tableau_view_csv ───────────────────────────────────────
80
+ export type TableauViewCsvArgs = { view: string; limit?: number; offset?: number };
81
+
82
+ /**
83
+ * Fetch a curated Tableau Server Guest view's COMPLETE CSV export (keyless) and
84
+ * page over it client-side. `view` is an allowlist enum; `limit`/`offset` page
85
+ * the parsed rows. Returns { view, columns, rows:[{col:value…}] } + honest _meta
86
+ * (totalAvailable = the complete row count, NOT a page length).
87
+ */
88
+ export async function viewCsv(args: TableauViewCsvArgs): Promise<MetaBundle> {
89
+ const v = VIEW_BY_KEY.get(args.view);
90
+ if (!v) {
91
+ throw new ToolErrorCarrier({
92
+ kind: "invalid_input",
93
+ message: `Unknown Tableau view ${JSON.stringify(args.view)}. Allowed: ${TABLEAU_VIEWS.map((s) => s.key).join(", ")}.`,
94
+ retryable: false,
95
+ });
96
+ }
97
+ const limit = args.limit ?? 50;
98
+ const offset = args.offset ?? 0;
99
+
100
+ // ── Fetch the full view CSV. getText THROWS on 429/5xx/404/timeout (P2). ──
101
+ const url = csvUrl(v);
102
+ const body = await getText(url, { label: `tableau:${v.key}`, redirect: "error", timeoutMs: 45_000 });
103
+
104
+ // P4: a non-CSV body (HTML sign-in / error page) ⇒ schema_drift, never parsed as empty.
105
+ const text = body.replace(/^/, ""); // strip a leading UTF-8 BOM if present
106
+ if (text.trim().length === 0 || /^\s*</.test(text)) {
107
+ throw driftError(
108
+ `tableau:${v.key}`,
109
+ "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.",
110
+ );
111
+ }
112
+
113
+ const table = parseCsv(text);
114
+ // P4: the first row MUST be a header (≥1 named column). No rows ⇒ drift.
115
+ if (table.length === 0 || !Array.isArray(table[0]) || table[0].length === 0) {
116
+ throw driftError(`tableau:${v.key}`, "Tableau CSV has no header row — schema drift.");
117
+ }
118
+ const header = table[0].map((h) => str(h) ?? "");
119
+ const dataRows = table.slice(1);
120
+ const totalAvailable = dataRows.length; // P1: complete view export = true total
121
+
122
+ const pageRows = dataRows.slice(offset, offset + limit).map((r) => {
123
+ const obj: Record<string, string | null> = {};
124
+ for (let i = 0; i < header.length; i++) obj[header[i] || `col${i}`] = str(r[i]);
125
+ return obj;
126
+ });
127
+ const returned = pageRows.length;
128
+ const hasMore = offset + returned < totalAvailable;
129
+ const nextOffset = hasMore ? offset + returned : null;
130
+
131
+ // P1 cap disclosure: a suspiciously round total may indicate a server export cap.
132
+ const roundCap = totalAvailable >= 1000 && totalAvailable % 1000 === 0;
133
+ const notes: string[] = [
134
+ `Source: ${v.label} (Tableau Server Guest CSV export, keyless). ${v.note}`,
135
+ "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.",
136
+ "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.",
137
+ VALUE_NOTE,
138
+ "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.",
139
+ ];
140
+ if (roundCap)
141
+ notes.push(
142
+ `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.`,
143
+ );
144
+
145
+ return withMeta(
146
+ { view: v.key, columns: header, rows: pageRows },
147
+ {
148
+ source: `${new URL(v.base).hostname} via Tableau Server Guest CSV (keyless)`,
149
+ keylessMode: true,
150
+ returned,
151
+ totalAvailable,
152
+ filtersApplied: ["view"],
153
+ filtersDropped: [],
154
+ fieldsUnavailable: [],
155
+ pagination: { offset, limit, hasMore, nextOffset },
156
+ notes,
157
+ } satisfies Partial<ResponseMeta>,
158
+ );
159
+ }