@cliwant/mcp-sam-gov 1.9.0 → 1.11.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.
Files changed (78) hide show
  1. package/README.ja.md +5 -5
  2. package/README.ko.md +5 -5
  3. package/README.md +19 -7
  4. package/dist/arcgis-feature.d.ts +68 -0
  5. package/dist/arcgis-feature.d.ts.map +1 -0
  6. package/dist/arcgis-feature.js +206 -0
  7. package/dist/arcgis-feature.js.map +1 -0
  8. package/dist/arcgis-hub.d.ts +90 -0
  9. package/dist/arcgis-hub.d.ts.map +1 -0
  10. package/dist/arcgis-hub.js +210 -0
  11. package/dist/arcgis-hub.js.map +1 -0
  12. package/dist/bonfire.d.ts +69 -0
  13. package/dist/bonfire.d.ts.map +1 -0
  14. package/dist/bonfire.js +212 -0
  15. package/dist/bonfire.js.map +1 -0
  16. package/dist/cbp-border.d.ts +9 -5
  17. package/dist/cbp-border.d.ts.map +1 -1
  18. package/dist/cbp-border.js +31 -11
  19. package/dist/cbp-border.js.map +1 -1
  20. package/dist/gao.d.ts.map +1 -1
  21. package/dist/gao.js +25 -0
  22. package/dist/gao.js.map +1 -1
  23. package/dist/grants.js +1 -1
  24. package/dist/grants.js.map +1 -1
  25. package/dist/gsa-perdiem.d.ts +16 -2
  26. package/dist/gsa-perdiem.d.ts.map +1 -1
  27. package/dist/gsa-perdiem.js +25 -4
  28. package/dist/gsa-perdiem.js.map +1 -1
  29. package/dist/integrity.d.ts.map +1 -1
  30. package/dist/integrity.js +18 -3
  31. package/dist/integrity.js.map +1 -1
  32. package/dist/lda.d.ts.map +1 -1
  33. package/dist/lda.js +17 -4
  34. package/dist/lda.js.map +1 -1
  35. package/dist/nhtsa.d.ts +9 -5
  36. package/dist/nhtsa.d.ts.map +1 -1
  37. package/dist/nhtsa.js +90 -26
  38. package/dist/nhtsa.js.map +1 -1
  39. package/dist/nist-controls.d.ts +3 -1
  40. package/dist/nist-controls.d.ts.map +1 -1
  41. package/dist/nist-controls.js +34 -12
  42. package/dist/nist-controls.js.map +1 -1
  43. package/dist/ofac.d.ts.map +1 -1
  44. package/dist/ofac.js +10 -1
  45. package/dist/ofac.js.map +1 -1
  46. package/dist/opengov.d.ts +96 -0
  47. package/dist/opengov.d.ts.map +1 -0
  48. package/dist/opengov.js +244 -0
  49. package/dist/opengov.js.map +1 -0
  50. package/dist/pricing.d.ts +1 -1
  51. package/dist/sam-gov/client.d.ts.map +1 -1
  52. package/dist/sam-gov/client.js +13 -4
  53. package/dist/sam-gov/client.js.map +1 -1
  54. package/dist/server.d.ts +4 -0
  55. package/dist/server.d.ts.map +1 -1
  56. package/dist/server.js +199 -8
  57. package/dist/server.js.map +1 -1
  58. package/dist/socrata.d.ts +27 -16
  59. package/dist/socrata.d.ts.map +1 -1
  60. package/dist/socrata.js +149 -18
  61. package/dist/socrata.js.map +1 -1
  62. package/package.json +11 -3
  63. package/src/arcgis-feature.ts +232 -0
  64. package/src/arcgis-hub.ts +270 -0
  65. package/src/bonfire.ts +249 -0
  66. package/src/cbp-border.ts +30 -9
  67. package/src/gao.ts +27 -0
  68. package/src/grants.ts +1 -1
  69. package/src/gsa-perdiem.ts +28 -4
  70. package/src/integrity.ts +18 -3
  71. package/src/lda.ts +21 -5
  72. package/src/nhtsa.ts +92 -27
  73. package/src/nist-controls.ts +58 -9
  74. package/src/ofac.ts +12 -1
  75. package/src/opengov.ts +309 -0
  76. package/src/sam-gov/client.ts +13 -4
  77. package/src/server.ts +213 -8
  78. package/src/socrata.ts +153 -18
@@ -353,11 +353,20 @@ export class SamGovClient {
353
353
  filters: SamSearchFilters,
354
354
  ): Promise<SamSearchResult> {
355
355
  const url = new URL(`${PUBLIC_BASE}/sgs/v1/search/`);
356
+ // Keyless HAL pagination is by PAGE (page × size), not a row offset — the
357
+ // list endpoint pages correctly (VERIFIED LIVE 2026-07: page=0 and page=1
358
+ // return disjoint result sets for the same query). Map the caller's row
359
+ // `offset` onto the page grid; a non-page-aligned offset snaps DOWN to its
360
+ // page boundary and we return the SERVED offset (page × size) so `_meta`
361
+ // never claims an offset the upstream didn't honor. (The authenticated path,
362
+ // buildAuthSearchUrl, honors an arbitrary `offset` directly.)
363
+ const size = filters.limit ?? 25;
364
+ const page = Math.max(0, Math.floor((filters.offset ?? 0) / size));
356
365
  url.searchParams.set("index", "opp");
357
- url.searchParams.set("page", "0");
366
+ url.searchParams.set("page", String(page));
358
367
  url.searchParams.set("mode", "search");
359
368
  url.searchParams.set("sort", "-modifiedDate");
360
- url.searchParams.set("size", String(filters.limit ?? 25));
369
+ url.searchParams.set("size", String(size));
361
370
  url.searchParams.set("is_active", "true");
362
371
  // Keyless HAL facet params — VERIFIED LIVE (2026-07). The list endpoint
363
372
  // honors these server-side: result counts drop correctly AND every returned
@@ -457,8 +466,8 @@ export class SamGovClient {
457
466
  });
458
467
  return {
459
468
  totalRecords,
460
- limit: filters.limit ?? 25,
461
- offset: filters.offset ?? 0,
469
+ limit: size,
470
+ offset: page * size, // the SERVED offset (page-aligned), never the raw request
462
471
  opportunitiesData: data,
463
472
  };
464
473
  }
package/src/server.ts CHANGED
@@ -48,6 +48,10 @@ import * as ckan from "./ckan.js";
48
48
  import * as echo from "./echo.js";
49
49
  import * as datagov from "./datagov.js";
50
50
  import * as datagovCatalog from "./datagov-catalog.js";
51
+ import * as arcgisHub from "./arcgis-hub.js";
52
+ import * as opengov from "./opengov.js";
53
+ import * as bonfire from "./bonfire.js";
54
+ import * as arcgisFeature from "./arcgis-feature.js";
51
55
  import * as govinfo from "./govinfo.js";
52
56
  import * as fpds from "./fpds.js";
53
57
  import * as nih from "./nih.js";
@@ -102,7 +106,7 @@ import { realpathSync } from "node:fs";
102
106
  const SERVER_NAME = "mcp-sam-gov";
103
107
  // Kept in lockstep with package.json / manifest.json / server.json.
104
108
  // Keep in sync with package.json "version" (asserted at release; see CHANGELOG).
105
- const SERVER_VERSION = "1.9.0";
109
+ const SERVER_VERSION = "1.11.0";
106
110
 
107
111
  // ─── Tool input schemas (Zod) ────────────────────────────────────
108
112
 
@@ -2975,6 +2979,113 @@ const DatagovSearchDatasetsInput = z.object({
2975
2979
  .describe("Opaque continuation cursor (→ after) — pass back the _meta.nextCursor from the previous page. Pagination is a cursor, NOT a numeric offset (offset/nextOffset are null); nextCursor:null means the last page. A bad token (spaces/'../'/'%') ⇒ invalid_input pre-fetch."),
2976
2980
  });
2977
2981
 
2982
+ // ─── ArcGIS Hub (hub.arcgis.com — keyless SLED dataset discovery) ─
2983
+ // Loop cycle 9. A GLOBAL, OPEN publishing platform (non-US / non-gov publishers
2984
+ // included) — DISCOVERY ONLY, provenance surfaced per-row + disclosed every
2985
+ // response. Fixed-host SSRF (no per-jurisdiction host vector). q ≥ 2 chars.
2986
+ const ArcgisHubDiscoverInput = z.object({
2987
+ query: z
2988
+ .string()
2989
+ .min(2)
2990
+ .max(500)
2991
+ .describe("Keyword search over ArcGIS Hub datasets (→ q), e.g. 'procurement contract', 'zoning permits'. REQUIRED, ≥2 non-whitespace chars (a broad scan of the whole global Hub is refused)."),
2992
+ openDataOnly: z
2993
+ .boolean()
2994
+ .default(true)
2995
+ .describe("When true (default), filter to items the publisher designated as open data (→ filter[openData]=true) — the B2G-relevant subset. Set false to broaden to ALL shared items (vet the publisher even more)."),
2996
+ limit: z
2997
+ .number()
2998
+ .int()
2999
+ .min(1)
3000
+ .max(100)
3001
+ .default(20)
3002
+ .describe("Datasets per page (→ page[size]), 1..100, default 20."),
3003
+ offset: z
3004
+ .number()
3005
+ .int()
3006
+ .min(0)
3007
+ .default(0)
3008
+ .describe("0-based record offset (→ page[start]=offset+1). Page with _meta.pagination.nextOffset; totalAvailable is the exact Hub match count."),
3009
+ });
3010
+
3011
+ // ─── OpenGov Procurement (api.procurement.opengov.com — keyless SLED bids) ─
3012
+ // SLED bid campaign. 525+ US state/local portals' live solicitations via the
3013
+ // public portal's own anonymous backend API (the key-gated api-key API is NOT
3014
+ // used). Fixed-host SSRF. governmentCode = the portal slug from list_governments.
3015
+ const OpengovListGovernmentsInput = z.object({
3016
+ state: z
3017
+ .string()
3018
+ .length(2)
3019
+ .optional()
3020
+ .describe("2-letter US state filter (client-side), e.g. 'CA', 'FL'. Optional."),
3021
+ query: z
3022
+ .string()
3023
+ .min(1)
3024
+ .max(120)
3025
+ .optional()
3026
+ .describe("Case-insensitive name substring filter (client-side), e.g. 'county', 'school'. Optional."),
3027
+ limit: z.number().int().min(1).max(200).default(50).describe("Portals per page, 1..200, default 50."),
3028
+ offset: z.number().int().min(0).default(0).describe("0-based offset; page with _meta.pagination.nextOffset. totalAvailable = exact filtered portal count."),
3029
+ });
3030
+
3031
+ const OpengovSearchSolicitationsInput = z.object({
3032
+ governmentCode: z
3033
+ .string()
3034
+ .min(1)
3035
+ .max(64)
3036
+ .regex(opengov.OPENGOV_CODE_RE)
3037
+ .describe("The OpenGov portal slug (from opengov_list_governments `code`), e.g. 'santacruzca', 'orlando', 'u-46'. REQUIRED. Lowercase alnum/hyphen; a bad slug ⇒ invalid_input pre-fetch."),
3038
+ limit: z.number().int().min(1).max(100).default(50).describe("Solicitations per page, 1..100, default 50 (→ API page size)."),
3039
+ offset: z.number().int().min(0).default(0).describe("0-based offset (snapped to the API's fixed page boundary). Page with _meta.pagination.nextOffset."),
3040
+ });
3041
+
3042
+ // ─── Bonfire (Euna) — keyless per-org open-opportunity RSS (SLED bids) ─
3043
+ // SLED bid campaign. Thousands of US state/local govs on Bonfire expose a keyless
3044
+ // RSS of open opportunities at {org}.bonfirehub.com/opportunities/rss. Ships a
3045
+ // curated 187-org seed directory. Fixed-suffix SSRF. org = charclass slug.
3046
+ const BonfireListOrganizationsInput = z.object({
3047
+ state: z.string().length(2).optional().describe("2-letter US state filter (client-side), e.g. 'TX', 'CA'. Optional."),
3048
+ query: z.string().min(1).max(120).optional().describe("Case-insensitive name substring filter (client-side), e.g. 'county', 'ISD'. Optional."),
3049
+ limit: z.number().int().min(1).max(200).default(50).describe("Orgs per page, 1..200, default 50."),
3050
+ offset: z.number().int().min(0).default(0).describe("0-based offset; page with _meta.pagination.nextOffset."),
3051
+ });
3052
+
3053
+ const BonfireSearchOpportunitiesInput = z.object({
3054
+ org: z
3055
+ .string()
3056
+ .min(1)
3057
+ .max(64)
3058
+ .regex(bonfire.BONFIRE_ORG_RE)
3059
+ .describe("The Bonfire org subdomain slug (from bonfire_list_organizations `org`), e.g. 'harriscountytx', 'broward', 'u-46'. REQUIRED. Lowercase alnum/hyphen; a bad slug ⇒ invalid_input pre-fetch."),
3060
+ limit: z.number().int().min(1).max(200).default(50).describe("Opportunities per page, 1..200, default 50. The RSS is the complete open set; this pages over it."),
3061
+ offset: z.number().int().min(0).default(0).describe("0-based offset; page with _meta.pagination.nextOffset. totalAvailable = the exact open-opportunity count."),
3062
+ });
3063
+
3064
+ // ─── ArcGIS REST feature query (curated service allowlist — SLED bids/GIS) ─
3065
+ // Generic ArcGIS FeatureServer/MapServer layer query over a curated allowlist.
3066
+ // First payload: DC OCP PASS procurement layers (live solicitations/contracts/PO/
3067
+ // payments). `service` is an enum (SSRF core); where/outFields filter the layer.
3068
+ const ArcgisFeatureQueryInput = z.object({
3069
+ service: z
3070
+ .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)."),
3072
+ where: z
3073
+ .string()
3074
+ .min(1)
3075
+ .max(500)
3076
+ .optional()
3077
+ .describe("ArcGIS SQL-ish filter (default '1=1'), e.g. \"SOLICITATIONTITLE LIKE '%security%'\" or \"DUE_DATE > 1750000000000\". Filters the read-only layer; a malformed clause ⇒ invalid_input (surfaced)."),
3078
+ outFields: z
3079
+ .string()
3080
+ .min(1)
3081
+ .max(500)
3082
+ .optional()
3083
+ .describe("Comma-separated fields to return (default '*' = all). e.g. 'SOLICITATIONNUMBER,SOLICITATIONTITLE,DUE_DATE,NIGPCODE'."),
3084
+ orderByFields: z.string().min(1).max(200).optional().describe("ArcGIS orderByFields, e.g. 'DUE_DATE DESC'. Optional."),
3085
+ limit: z.number().int().min(1).max(1000).default(50).describe("Records per page (→ resultRecordCount), 1..1000, default 50."),
3086
+ offset: z.number().int().min(0).default(0).describe("0-based offset (→ resultOffset). Page with _meta.pagination.nextOffset; totalAvailable = the layer's exact match count."),
3087
+ });
3088
+
2978
3089
  // ─── GovInfo (api.govinfo.gov — the api.data.gov keyed trio's 3rd API) ─
2979
3090
  // ADR-0010. Same DATA_GOV_API_KEY/DEMO_KEY/X-Api-Key discipline as the datagov
2980
3091
  // trio (shared datagovKey.ts seam). `collection` is grammar-checked here AND
@@ -4307,7 +4418,7 @@ const GsaPerdiemRatesInput = z
4307
4418
  .regex(/^\d{4}$/)
4308
4419
  .optional()
4309
4420
  .describe(
4310
- "The per-diem fiscal year (default '2025'). Validated ^\\d{4}$ (it rides in the request path).",
4421
+ "The per-diem fiscal year (default: the current U.S. federal fiscal year, computed at call time — GSA sets rates per FY, Oct 1–Sep 30). Validated ^\\d{4}$ (it rides in the request path).",
4311
4422
  ),
4312
4423
  })
4313
4424
  .describe(
@@ -4432,7 +4543,7 @@ const LdaSearchFilingsInput = z.object({
4432
4543
  .string()
4433
4544
  .min(1)
4434
4545
  .optional()
4435
- .describe("Filter by the federal government entity lobbied (maps to government_entity — the B2G signal), e.g. 'DEPARTMENT OF DEFENSE'."),
4546
+ .describe("NOTE: the keyless /filings/ endpoint has NO server-side government-entity filter — the LDA API silently ignores it, so this value is NOT applied (reported in _meta.filtersDropped, never as a narrowed total). Government entities are nested per lobbying activity (each filing's lobbyingActivities[].governmentEntities); to find who lobbied an agency, narrow by registrantName/clientName/issue and inspect those nested entities. Retained for discoverability of the limitation."),
4436
4547
  issue: z
4437
4548
  .string()
4438
4549
  .min(1)
@@ -4784,6 +4895,21 @@ export const TOOLS: ToolDef[] = [
4784
4895
  "The organization-name filter is NOT supported by the keyless endpoint and was ignored (results are unfiltered on organization). Set SAM_GOV_API_KEY to filter by organization, or filter client-side on the returned `agency` field.",
4785
4896
  );
4786
4897
  }
4898
+ // Pagination honesty: the keyless HAL endpoint pages by PAGE (page × size),
4899
+ // not an arbitrary row offset, so a requested offset that is not a multiple
4900
+ // of `limit` snaps DOWN to its page boundary. Disclose the snap so the AI
4901
+ // never believes it read rows [offset..offset+limit) when it actually got
4902
+ // the page-aligned window. (An aligned offset — the default paging pattern
4903
+ // of incrementing by `limit` — is served exactly, so no note.)
4904
+ {
4905
+ const reqOffset = input.offset ?? 0;
4906
+ const size = input.limit ?? 25;
4907
+ if (reqOffset % size !== 0) {
4908
+ notes.push(
4909
+ `Keyless SAM pagination is page-based (page × ${size}); the requested offset ${reqOffset} was snapped DOWN to the page boundary ${Math.floor(reqOffset / size) * size}. Page through by incrementing offset in multiples of limit (set SAM_GOV_API_KEY for exact row offsets).`,
4910
+ );
4911
+ }
4912
+ }
4787
4913
  notes.push(...enrichmentNotes);
4788
4914
 
4789
4915
  // freshness is surfaced structurally in `data` (the ResponseMeta type
@@ -5543,7 +5669,7 @@ export const TOOLS: ToolDef[] = [
5543
5669
  defineTool({
5544
5670
  name: "nist_800_53_controls",
5545
5671
  description:
5546
- "Look up NIST SP 800-53 Rev 5 security & privacy CONTROLS (keyless) — the requirement backbone for FedRAMP / CMMC / RMF compliance work. Retrieve a control by `controlId` (exact, e.g. 'AC-2', 'SC-7', 'AC-2(1)'), a `family` (2-letter code 'AC'/'SC'/'IA' or a name substring 'Access Control'), and/or a `keyword` (case-insensitive substring over title + statement); `limit`/`offset` pagination. Each row: { id (e.g. 'AC-2'), family (e.g. 'AC — Access Control'), title, statement (the labelled requirement prose), guidance (discussion), enhancements:[{id,title}] (e.g. AC-2(1)) }. Complements cve_lookup + cisa_kev_lookup (the vulnerability side) with the CONTROL/requirement side. HONESTY: source is NIST's OFFICIAL OSCAL catalog published at github.com/usnistgov/oscal-content (authoritative first-party data served from GitHub, not a .gov API host — provenance disclosed in _meta); the catalog has no query API so filtering is CLIENT-SIDE and totalAvailable is the EXACT match count; this is the REQUIREMENT text only — applicability depends on the system's FIPS-199 impact baseline (Low/Moderate/High), which the catalog does not encode (disclosed); a download failure or an implausibly-truncated catalog (< 15 families) THROWS (never a fake-empty 'control not found').",
5672
+ "Look up NIST SP 800-53 Rev 5 security & privacy CONTROLS (keyless) — the requirement backbone for FedRAMP / CMMC / RMF compliance work. Retrieve a control by `controlId` (exact, e.g. 'AC-2', 'SC-7', 'AC-2(1)'), a `family` (2-letter code 'AC'/'SC'/'IA' or a name substring 'Access Control'), and/or a `keyword` (case-insensitive substring over title + statement); `limit`/`offset` pagination. Each row: { id (e.g. 'AC-2'), family (e.g. 'AC — Access Control'), title, status ('withdrawn' | null), statement (the labelled requirement prose; NULL for a WITHDRAWN control, never ''), guidance (discussion), incorporatedInto:[control ids that superseded a withdrawn control, e.g. AC-13 → ['AC-2','AU-6']], enhancements:[{id,title}] (e.g. AC-2(1)) }. Complements cve_lookup + cisa_kev_lookup (the vulnerability side) with the CONTROL/requirement side. HONESTY: source is NIST's OFFICIAL OSCAL catalog published at github.com/usnistgov/oscal-content (authoritative first-party data served from GitHub, not a .gov API host — provenance disclosed in _meta); the exact OSCAL version + last-modified are surfaced in _meta (the catalog is fetched live from the MOVING 'main' branch, so control text can shift between point releases, e.g. 5.1.1 → 5.2.0 — cite the version, not just 'Rev 5'); a WITHDRAWN control (status:'withdrawn') has statement:null and is NOT an active requirement (see incorporatedInto for what replaced it); the catalog has no query API so filtering is CLIENT-SIDE and totalAvailable is the EXACT match count; this is the REQUIREMENT text only — applicability depends on the system's FIPS-199 impact baseline (Low/Moderate/High), which the catalog does not encode (disclosed); a download failure or an implausibly-truncated catalog (< 15 families) THROWS (never a fake-empty 'control not found').",
5547
5673
  inputSchema: NistControlsInput,
5548
5674
  handler: (input) => nistControls.searchControls(input),
5549
5675
  }),
@@ -6030,6 +6156,72 @@ export const TOOLS: ToolDef[] = [
6030
6156
  inputSchema: DatagovSearchDatasetsInput,
6031
6157
  handler: (input) => datagovCatalog.searchDatasets(input),
6032
6158
  }),
6159
+ // ━━━ ArcGIS Hub (hub.arcgis.com) — keyless SLED dataset DISCOVERY (loop cycle 9) ━━━
6160
+ // A NEW discovery platform: much US state/local/regional/tribal (SLED) open data
6161
+ // — GIS/infrastructure/permits/boundaries/procurement — lives on ArcGIS Hub, NOT
6162
+ // Socrata/CKAN. Fixed-host SSRF (no per-jurisdiction host vector). ★PROVENANCE: the
6163
+ // Hub is GLOBAL + OPEN (non-US/non-gov publishers), so this is a DISCOVERY aid, not
6164
+ // a curated official-source allowlist — per-row owner/orgName/source/region are
6165
+ // surfaced for vetting + the disclosure rides every response. P1: totalAvailable =
6166
+ // meta.total (exact Hub count). DISCOVERY ONLY (row-query on arbitrary ArcGIS hosts
6167
+ // is a planned, separately-guarded addition).
6168
+ defineTool({
6169
+ name: "arcgis_hub_discover_datasets",
6170
+ description:
6171
+ "Discover ArcGIS Hub datasets by keyword — the SLED/GIS open-data layer that Socrata and CKAN do NOT cover (keyless; hub.arcgis.com/api/v3/datasets). Much US state/local/regional/tribal open data (GIS, infrastructure, permits, zoning, boundaries, procurement) is published on ArcGIS Hub. Input `query` (→q, REQUIRED, ≥2 non-whitespace chars — a broad whole-Hub scan is refused), `openDataOnly` (default TRUE → filter[openData]=true, the B2G-relevant designated-open-data subset; false broadens to all shared items), `limit` (1..100, def 20 → page[size]), `offset` (0-based → page[start]=offset+1). Returns { query, openDataOnly, datasets:[{ id, name, description, owner, orgName, source, region, type, sector, keywords, downloadable, hasApi, created, modified, landingPage, itemId }] } + honest _meta. ★PROVENANCE (the crux — a DIFFERENT trust posture from our other sources): ArcGIS Hub is a GLOBAL, OPEN publishing platform — results include NON-US and NON-GOVERNMENTAL publishers. This is a DISCOVERY aid, NOT a curated official-source allowlist (unlike socrata_query): the per-row owner/orgName/source/region are surfaced VERBATIM so you can VET the publisher before relying on the data, and the global-platform caveat rides EVERY response. DISCOVERY ONLY — metadata + links; to read rows follow the dataset on its own ArcGIS endpoint (a guarded row-query tool is a planned addition). HONESTY: totalAvailable = the EXACT Hub match count (meta.total, NEVER data.length — P1); pagination is a 0-based offset (nextOffset when more remain); every scalar null-never-empty, booleans null-preserving, counts null-never-0; a genuine no-match ⇒ complete:true/returned:0; a 429 ⇒ rate_limited / 5xx/timeout ⇒ upstream_unavailable THROWS (never a fake empty); a 200 non-JSON / non-array data ⇒ schema_drift.",
6172
+ inputSchema: ArcgisHubDiscoverInput,
6173
+ handler: (input) => arcgisHub.discoverDatasets(input),
6174
+ }),
6175
+ // ━━━ OpenGov Procurement (api.procurement.opengov.com) — keyless SLED bids ━━━
6176
+ // SLED bid campaign (from the exhaustive US state+local bid-site research). 525+
6177
+ // state/local government portals' LIVE solicitations, via the public portal's OWN
6178
+ // anonymous backend REST API — the key-gated official api-key API is NOT touched
6179
+ // (genuinely keyless, live-verified). Fixed-host SSRF. Two tools: a directory
6180
+ // (one keyless GET returns all ~560 orgs) + per-org solicitations.
6181
+ defineTool({
6182
+ name: "opengov_list_governments",
6183
+ description:
6184
+ "List the government portals on OpenGov Procurement (formerly ProcureNow) — the directory for opengov_search_solicitations (keyless; api.procurement.opengov.com). OpenGov Procurement hosts the live open-solicitation portals of 525+ US state/local governments (cities, counties, school & special districts across 42 states + DC). The WHOLE directory arrives in ONE keyless GET and is filtered client-side: `state` (2-letter), `query` (case-insensitive name substring); `limit`(1..200)/`offset`. Only ACTIVE, non-internal portals are returned. Output: { governments:[{ code, name, city, state, website }] } + honest _meta. Feed a result's `code` to opengov_search_solicitations. HONESTY: this consumes ONLY the anonymous endpoints the public portal itself calls (the official key-gated api-key API is NOT used) — genuinely keyless; totalAvailable is the EXACT filtered portal count (never the page length); a 429/5xx/timeout THROWS (never a fake empty); a non-array body ⇒ schema_drift.",
6185
+ inputSchema: OpengovListGovernmentsInput,
6186
+ handler: (input) => opengov.listGovernments(input),
6187
+ }),
6188
+ defineTool({
6189
+ name: "opengov_search_solicitations",
6190
+ description:
6191
+ "List a government's public solicitations on OpenGov Procurement (keyless; api.procurement.opengov.com, POST /project/list with the required publicView gate). Input `governmentCode` (the portal slug from opengov_list_governments, e.g. 'santacruzca', 'orlando', 'u-46'; REQUIRED), `limit`(1..100)/`offset`. Returns { governmentCode, solicitations:[{ id, title, solicitationNumber, status, type, department, releaseDate, proposalDeadline, contactName, link }] } + honest _meta. ★STATUS: `status` is surfaced VERBATIM — **open = currently ACCEPTING responses**; pending/evaluation/closed are ALSO returned (publicView shows all public projects), so filter status==='open' for live bids. `link` is the public portal page. HONESTY: totalAvailable = the API's `count` = the org's TOTAL public-project count (all statuses), NEVER the page length and NOT an open-only count (a note discloses this); pagination is the API's fixed page (offset is snapped to the page boundary, disclosed); a genuine no-match ⇒ complete:true/returned:0; a 429/5xx/timeout THROWS (never a fake empty); a non-array `projects` ⇒ schema_drift; a bad `governmentCode` ⇒ invalid_input pre-fetch. Genuinely keyless (the key-gated official API is NOT used).",
6192
+ inputSchema: OpengovSearchSolicitationsInput,
6193
+ handler: (input) => opengov.searchSolicitations(input),
6194
+ }),
6195
+ // ━━━ Bonfire (Euna) — keyless per-org open-opportunity RSS (SLED bids) ━━━
6196
+ // 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
6198
+ // (Bonfire's authoritative org API is auth-gated → out of bounds). Fixed-suffix
6199
+ // SSRF (.bonfirehub.com). RSS = the complete open set (totalAvailable honest).
6200
+ defineTool({
6201
+ name: "bonfire_list_organizations",
6202
+ 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.",
6204
+ inputSchema: BonfireListOrganizationsInput,
6205
+ handler: (input) => bonfire.listOrganizations(input),
6206
+ }),
6207
+ defineTool({
6208
+ name: "bonfire_search_opportunities",
6209
+ description:
6210
+ "List a government's currently-OPEN solicitations on Bonfire (keyless; {org}.bonfirehub.com/opportunities/rss, RSS 2.0). Input `org` (the subdomain slug from bonfire_list_organizations, e.g. 'harriscountytx', 'broward', 'u-46'; REQUIRED), `limit`(1..200)/`offset`. Returns { org, opportunities:[{ referenceNumber, name, description, closeDate, link, pubDate }] } + honest _meta. HONESTY: the RSS is the COMPLETE set of the org's currently-open opportunities (no server pagination), so totalAvailable = the exact open-opportunity count (never a page length) and this tool pages over it client-side; an empty feed (returned 0) means no open opportunities right now (honest empty, complete:true); `closeDate` is parsed best-effort from the description; a 429/5xx/404/timeout THROWS (never a fake empty); a 200 non-RSS body ⇒ schema_drift; a bad `org` ⇒ invalid_input pre-fetch. Fixed-suffix SSRF (.bonfirehub.com) + redirect:error. Keyless (Bonfire's auth-gated directory API is NOT used).",
6211
+ inputSchema: BonfireSearchOpportunitiesInput,
6212
+ handler: (input) => bonfire.searchOpportunities(input),
6213
+ }),
6214
+ // ━━━ ArcGIS REST feature query (curated allowlist) — SLED bids/GIS ━━━
6215
+ // Generic query companion to arcgis_hub_discover_datasets. First payload: the DC
6216
+ // OCP PASS procurement layers (live solicitations + contracts/PO/payments), a
6217
+ // keyless live-solicitation feed via ArcGIS REST. `service` enum = SSRF core.
6218
+ defineTool({
6219
+ name: "arcgis_feature_query",
6220
+ description:
6221
+ "Query rows from a curated US-government ArcGIS REST feature layer (keyless) — the QUERY companion to arcgis_hub_discover_datasets (which discovers Hub datasets). A large amount of SLED procurement/GIS data lives on ArcGIS. First payload: the **DC Office of Contracting & Procurement 'PASS'** layers — `dc_pass_solicitations` (DC's LIVE open solicitations, ~25k: SOLICITATIONNUMBER, SOLICITATIONTITLE, DUE_DATE, OPENDATE, CLOSEDATE, NIGPCODE, CONTRACTINGOFFICER, AWARD_TO, 46 fields), `dc_pass_contracts` (~50k), `dc_pass_purchase_orders` (~275k), `dc_pass_payments` (~1.55M). Inputs: `service` (the allowlist ENUM — the SSRF core, never a free host), `where` (ArcGIS SQL-ish filter, default '1=1', e.g. \"SOLICITATIONTITLE LIKE '%security%'\"), `outFields` (default '*'), `orderByFields`, `limit`(1..1000)/`offset`. Returns { service, records:[{…attributes verbatim…}] } + honest _meta. HONESTY: totalAvailable = the layer's EXACT match count (a returnCountOnly companion query, never the page length; a count failure ⇒ null + note, rows still returned); ★ArcGIS date fields are epoch MILLISECONDS and a negative/sentinel (≈1900) is a placeholder — surfaced verbatim, never coerced; a genuine no-match ⇒ complete:true/returned:0; a 429/5xx/timeout THROWS; an ArcGIS {error} body (e.g. a bad where) ⇒ invalid_input/upstream (surfaced, never a fake empty); a non-array features ⇒ schema_drift. SSRF: fixed allowlist base + hostname assertion + redirect:error (where/outFields cannot alter the host).",
6222
+ inputSchema: ArcgisFeatureQueryInput,
6223
+ handler: (input) => arcgisFeature.featureQuery(input),
6224
+ }),
6033
6225
  // ━━━ GovInfo (api.govinfo.gov) — the api.data.gov keyed trio's 3rd API (3) ━━━ ADR-0010
6034
6226
  // GPO-authoritative bulk publications (BILLS/PLAW/USCODE/CREC/CFR-FR editions/
6035
6227
  // BUDGET/GAOREPORTS) with PDF/XML/MODS downloads + provenance. 2nd consumer of the
@@ -6269,7 +6461,7 @@ export const TOOLS: ToolDef[] = [
6269
6461
  defineTool({
6270
6462
  name: "cbp_border_wait_times",
6271
6463
  description:
6272
- "Live CBP land-border-port wait times — current commercial-vehicle (and passenger) crossing delays at every US Canadian- and Mexican-border port (keyless; bwt.cbp.gov). The FREIGHT / LOGISTICS situational-awareness lane: per-port commercial-vehicle standard + FAST lane delay (minutes), operational status, open-lane count, and maximum lanes. Filters (optional): `border` (case-insensitive substring, 'Canadian'/'Mexican'), `portName` (substring, e.g. 'Laredo'); `limit`/`offset` pagination. Each row: { portNumber, portName, crossingName, border, portStatus (Open/Closed), asOf, commercialVehicle:{ maxLanes, standard:{operationalStatus, delayMinutes, lanesOpen, updateTime}, fast:{…} } }. HONESTY: this is REAL-TIME operational data — each lane carries its own updateTime (surfaced verbatim; freshness never implied live-to-the-second); delayMinutes/lanesOpen are number|null (a real 0 stays 0; an empty/N/A value — e.g. a closed lane — is null, NEVER a fabricated 0, because a closed lane's delay is UNKNOWN, not zero); the API returns the WHOLE port set so totalAvailable is the EXACT matched-port count; an outage/4xx/timeout THROWS and a non-array body ⇒ schema_drift (never a fake empty).",
6464
+ "Live CBP land-border-port wait times — current commercial-vehicle (freight-truck) crossing delays at every US Canadian- and Mexican-border port (keyless; bwt.cbp.gov). The FREIGHT / LOGISTICS situational-awareness lane: per-port commercial-vehicle standard + FAST lane delay (minutes), operational status, open-lane count, and maximum lanes — passenger/pedestrian lanes are NOT surfaced (freight lane only). Filters (optional, applied CLIENT-SIDE over the full fetched port set — the feed has NO server-side filter; an empty-string value is reported in _meta.filtersDropped, not applied): `border` (case-insensitive substring, 'Canadian'/'Mexican'), `portName` (substring, e.g. 'Laredo'); `limit`/`offset` pagination. Each row: { portNumber, portName, crossingName, border, portStatus (Open/Closed), asOf, commercialVehicle:{ maxLanes, standard:{operationalStatus, delayMinutes, lanesOpen, updateTime}, fast:{…} } }. HONESTY: this is REAL-TIME operational data — each lane carries its own updateTime (surfaced verbatim; freshness never implied live-to-the-second); delayMinutes/lanesOpen are number|null (a real 0 stays 0; an empty/N/A value — e.g. a closed lane — is null, NEVER a fabricated 0, because a closed lane's delay is UNKNOWN, not zero); the API returns the WHOLE port set so totalAvailable is the EXACT matched-port count; an outage/4xx/timeout THROWS and a non-array body ⇒ schema_drift (never a fake empty).",
6273
6465
  inputSchema: CbpBorderWaitInput,
6274
6466
  handler: (input) => cbpBorder.borderWaitTimes(input),
6275
6467
  }),
@@ -6298,7 +6490,7 @@ export const TOOLS: ToolDef[] = [
6298
6490
  defineTool({
6299
6491
  name: "gsa_perdiem_rates",
6300
6492
  description:
6301
- "Look up GSA Federal Travel PER-DIEM rates — the max lodging + Meals & Incidental Expenses (M&IE) reimbursement ceilings for official U.S. government travel (api.gsa.gov /travel/perdiem/v2, keyed — DATA_GOV_API_KEY or the shared DEMO_KEY). Input: EITHER `city` (e.g. 'Washington') + `state` (2-letter, e.g. 'DC') OR `zip` (5-digit) — supplying BOTH, or NEITHER, ⇒ invalid_input with 0 fetch; optional `year` (default '2025'). Returns { rates:[{ city, county, state, zip, year, isOconus, standardRate, mealsUsd, monthlyLodgingUsd:[{ month (1-12), monthName, lodgingUsd }] }] } + honest _meta. HONESTY: lodgingUsd (the API's monthly `value`) is the MAX nightly lodging ceiling for that month — it VARIES SEASONALLY (hence a per-month array), and mealsUsd is the daily M&IE ceiling; both are integer US dollars, null-when-withheld (NEVER 0 — a genuine 0 is preserved). standardRate/isOconus are booleans coerced from the API's string 'true'/'false' (an unrecognized value ⇒ null, never a fabricated false); the months array is preserved AS-IS (never padded to 12). The API returns the COMPLETE rate set (no pagination) ⇒ totalAvailable = the row count, complete:true. A genuine no-match (rates:[]/rate:[]) ⇒ honest empty (returned:0); the API's `errors` field non-null ⇒ invalid_input carrying the message (never a fake empty); a 429 (DEMO_KEY ~10 req/hr, hit quickly) ⇒ rate_limited THROWS; a 5xx/timeout ⇒ upstream_unavailable THROWS; a 200 non-JSON ⇒ schema_drift. DEMO_KEY ~10 req/hr shared ceiling — set DATA_GOV_API_KEY (free at api.data.gov/signup) for 1000/hr. The key rides ONLY in the X-Api-Key header (never the URL/_meta).",
6493
+ "Look up GSA Federal Travel PER-DIEM rates — the max lodging + Meals & Incidental Expenses (M&IE) reimbursement ceilings for official U.S. government travel (api.gsa.gov /travel/perdiem/v2, keyed — DATA_GOV_API_KEY or the shared DEMO_KEY). Input: EITHER `city` (e.g. 'Washington') + `state` (2-letter, e.g. 'DC') OR `zip` (5-digit) — supplying BOTH, or NEITHER, ⇒ invalid_input with 0 fetch; optional `year` (default: the current U.S. federal fiscal year). Returns { rates:[{ city, county, state, zip, year, isOconus, standardRate, mealsUsd, monthlyLodgingUsd:[{ month (1-12), monthName, lodgingUsd }] }] } + honest _meta. HONESTY: lodgingUsd (the API's monthly `value`) is the MAX nightly lodging ceiling for that month — it VARIES SEASONALLY (hence a per-month array), and mealsUsd is the daily M&IE ceiling; both are integer US dollars, null-when-withheld (NEVER 0 — a genuine 0 is preserved). standardRate/isOconus are booleans coerced from the API's string 'true'/'false' (an unrecognized value ⇒ null, never a fabricated false); the months array is preserved AS-IS (never padded to 12). The API returns the COMPLETE rate set (no pagination) ⇒ totalAvailable = the row count, complete:true. A genuine no-match (rates:[]/rate:[]) ⇒ honest empty (returned:0); the API's `errors` field non-null ⇒ invalid_input carrying the message (never a fake empty); a 429 (DEMO_KEY ~10 req/hr, hit quickly) ⇒ rate_limited THROWS; a 5xx/timeout ⇒ upstream_unavailable THROWS; a 200 non-JSON ⇒ schema_drift. DEMO_KEY ~10 req/hr shared ceiling — set DATA_GOV_API_KEY (free at api.data.gov/signup) for 1000/hr. The key rides ONLY in the X-Api-Key header (never the URL/_meta).",
6302
6494
  inputSchema: GsaPerdiemRatesInput,
6303
6495
  handler: (input) => gsaPerdiem.perdiemRates(input),
6304
6496
  }),
@@ -6333,7 +6525,7 @@ export const TOOLS: ToolDef[] = [
6333
6525
  defineTool({
6334
6526
  name: "lda_search_filings",
6335
6527
  description:
6336
- "Search US Senate LDA (Lobbying Disclosure Act) filings — who is paid HOW MUCH to lobby WHICH federal agency on WHICH issue (lda.senate.gov/api/v1/filings, KEYLESS — anonymous access works; an optional free LDA_API_KEY only raises the rate limit). All inputs optional: `registrantName` (the lobbying firm/in-house filer), `clientName` (who it's for), `lobbyistName`, `filingYear` (4-digit), `filingType` (short code, e.g. 'Q1'/'RR'/'YE'), `agency` (the federal government_entity lobbied — the B2G signal), `issue` (specific lobbying issues text), `page` (1-based, default 1), `pageSize` (1..25, default 25). Returns { filings:[{ filingUuid, filingType, filingYear, filingPeriod, incomeUsd, expensesUsd, registrant, client, lobbyingActivities:[{ issueCode, description, governmentEntities:[names] }], documentUrl, postedDate, terminationDate }] } + honest _meta. HONESTY: totalAvailable is the API's REAL total match count (the corpus is ~1.95M filings) — NOT the rows on this page; pagination is page-based (pass the next page number when hasMore). incomeUsd/expensesUsd are parsed from the null-or-decimal-string income/expenses — null (not reported) ⇒ null, NEVER 0 (a genuine 0 stays 0); a filing reports EITHER income OR expenses, so the other is typically null. Missing lobbying_activities/government_entities ⇒ empty arrays (never fabricated). A genuine no-match (results:[]) ⇒ honest empty (returned:0); a 400 (bad filter) ⇒ invalid_input surfacing the API's message; a 429 ⇒ rate_limited THROWS (Retry-After honored, never routed around); a 5xx/timeout ⇒ upstream_unavailable THROWS; a 200 non-JSON / non-array results / non-number count ⇒ schema_drift. The optional key rides ONLY in the Authorization: Token header (never the URL/_meta).",
6528
+ "Search US Senate LDA (Lobbying Disclosure Act) filings — who is paid HOW MUCH to lobby WHICH federal agency on WHICH issue (lda.senate.gov/api/v1/filings, KEYLESS — anonymous access works; an optional free LDA_API_KEY only raises the rate limit). All inputs optional: `registrantName` (the lobbying firm/in-house filer), `clientName` (who it's for), `lobbyistName`, `filingYear` (4-digit), `filingType` (short code, e.g. 'Q1'/'RR'/'YE'), `agency` (NOTE: /filings/ has NO server-side agency filter — the LDA API silently ignores it, so it is reported in _meta.filtersDropped and NOT applied; government entities are nested per activity in lobbyingActivities[].governmentEntities), `issue` (specific lobbying issues text), `page` (1-based, default 1), `pageSize` (1..25, default 25). Returns { filings:[{ filingUuid, filingType, filingYear, filingPeriod, incomeUsd, expensesUsd, registrant, client, lobbyingActivities:[{ issueCode, description, governmentEntities:[names] }], documentUrl, postedDate, terminationDate }] } + honest _meta. HONESTY: totalAvailable is the API's REAL total match count (the corpus is ~1.95M filings) — NOT the rows on this page; pagination is page-based (pass the next page number when hasMore). incomeUsd/expensesUsd are parsed from the null-or-decimal-string income/expenses — null (not reported) ⇒ null, NEVER 0 (a genuine 0 stays 0); a filing reports EITHER income OR expenses, so the other is typically null. Missing lobbying_activities/government_entities ⇒ empty arrays (never fabricated). A genuine no-match (results:[]) ⇒ honest empty (returned:0); a 400 (bad filter) ⇒ invalid_input surfacing the API's message; a 429 ⇒ rate_limited THROWS (Retry-After honored, never routed around); a 5xx/timeout ⇒ upstream_unavailable THROWS; a 200 non-JSON / non-array results / non-number count ⇒ schema_drift. The optional key rides ONLY in the Authorization: Token header (never the URL/_meta).",
6337
6529
  inputSchema: LdaSearchFilingsInput,
6338
6530
  handler: (input) => lda.searchFilings(input),
6339
6531
  }),
@@ -6619,7 +6811,7 @@ export async function runTool(
6619
6811
  /**
6620
6812
  * Hand-rolled Zod → JSON Schema converter (subset we use).
6621
6813
  */
6622
- function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
6814
+ export function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
6623
6815
  const def = (schema as unknown as { _def: { typeName: string } })._def;
6624
6816
  const tn = def.typeName;
6625
6817
  const description = (schema as unknown as { description?: string }).description;
@@ -6671,6 +6863,19 @@ function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
6671
6863
  const innerSchema = zodToJsonSchema(inner);
6672
6864
  return description ? { ...innerSchema, description } : innerSchema;
6673
6865
  }
6866
+ if (tn === "ZodEffects") {
6867
+ // .refine() / .superRefine() / .transform() wrap the REAL schema at
6868
+ // `_def.schema` (used for cross-field rules like "npi OR state required").
6869
+ // Without this branch these fall through to the {type:"string"} default,
6870
+ // publishing a degenerate object-less inputSchema that a schema-driven MCP
6871
+ // client cannot construct a call against — even though the runtime Zod still
6872
+ // demands the full object. Unwrap so the published schema keeps its real
6873
+ // properties / required / enums.
6874
+ const inner = (schema as unknown as { _def: { schema: z.ZodTypeAny } })._def
6875
+ .schema;
6876
+ const innerSchema = zodToJsonSchema(inner);
6877
+ return description ? { ...innerSchema, description } : innerSchema;
6878
+ }
6674
6879
  return { type: "string", ...(description ? { description } : {}) };
6675
6880
  }
6676
6881
 
package/src/socrata.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Socrata / SODA — keyless open data for state / local (SLED) + E-rate portals.
2
+ * Socrata / SODA — keyless open data for state / local (SLED) + federal + E-rate portals.
3
3
  *
4
4
  * First SLED source (ADR-0004); 3rd consumer of the fetch/map/meta shape after
5
5
  * treasury.ts / edgar.ts. Fully PUBLIC, KEYLESS. ONE connector reaches ~a dozen
@@ -7,7 +7,8 @@
7
7
  * 4x4 dataset id. Hosts are a CURATED allowlist (see SSRF below); the caller
8
8
  * never supplies a free host or a free path.
9
9
  * Row query: https://{domain}/resource/{4x4}.json?$select=…&$where=…&$limit=…
10
- * Catalog: https://api.us.socrata.com/api/catalog/v1?domains={domain}&q=…
10
+ * Catalog (host-scoped): https://{domain}/api/catalog/v1?search_context={domain}&q=…
11
+ * Catalog (all-host): https://api.us.socrata.com/api/catalog/v1?domains=…&q=…
11
12
  *
12
13
  * Three layers (mirror treasury.ts / edgar.ts):
13
14
  * fetch — `getSocrataResource` / `getCatalog`: SSRF-guard, build the URL,
@@ -67,14 +68,19 @@
67
68
  * wrong shape (possible upstream API change). A hard schema_drift throw is
68
69
  * reserved for a PRIMARY query (rows / catalog), NEVER the secondary count.
69
70
  *
70
- * ALLOWLIST — LIVE-VERIFIED 2026-07-10 (each carries a real sample 4x4). All
71
- * `.gov` except `opendata.usac.org`:
71
+ * ALLOWLIST — each entry carries a real sample 4x4, live-verified on its tier's
72
+ * date (base state slice 2026-07-10; local + major-city + federal 2026-07-18).
73
+ * All `.gov` except the documented non-.gov exceptions: `opendata.usac.org` and
74
+ * the four major-city portals (.us/.org — see the inline blocks below):
72
75
  * m6 — `opendata.usac.org` is a `.org` (USAC, a Congress-designated non-profit;
73
76
  * E-rate). It is on the periodic re-verification checklist. NOTE: the
74
- * federated discovery catalog (api.us.socrata.com) does NOT index USAC
75
- * (returns resultSetSize 0), so `socrata_discover_datasets` will not
76
- * surface it — but `socrata_query` works against it with a known 4x4
77
- * (live: opendata.usac.org/resource/avi8-svp9.json → 200 bare array).
77
+ * FEDERATED discovery catalog (api.us.socrata.com) does NOT index USAC
78
+ * (resultSetSize 0), so the ALL-HOST `socrata_discover_datasets` (domain
79
+ * omitted) won't surface it; as of loop cycle 8 a DOMAIN-scoped discover
80
+ * uses USAC's own catalog (search_context) and DOES surface it (live:
81
+ * opendata.usac.org/api/catalog/v1?search_context=… → resultSetSize 8).
82
+ * `socrata_query` works regardless with a known 4x4 (live:
83
+ * opendata.usac.org/resource/avi8-svp9.json → 200 bare array).
78
84
  * M1 — MA is DROPPED from slice 1: `cthru.data.socrata.com` is a commercial
79
85
  * vendor host (Tyler Technologies `.socrata.com`, not gov-controlled) and
80
86
  * no `.gov` MA Socrata host verifies (`data.mass.gov` → the catalog
@@ -139,7 +145,85 @@ export const SOCRATA_DOMAINS = [
139
145
  "data.montgomerycountymd.gov", // Montgomery County MD — e.g. vmu2-pnrc (Contracts)
140
146
  "data.mesaaz.gov", // Mesa AZ (city) — e.g. j7s9-qiuq (Vendor Payments)
141
147
  "data.cambridgema.gov", // Cambridge MA (city) — e.g. gp98-ja4f (Contracts bid list)
148
+ // ── Federal open-data portal tier (loop cycle 7, 2026-07-18). The FIRST
149
+ // federal Socrata hosts (prior tiers were state + local only); all .gov,
150
+ // host-scoped catalog + /resource/<4x4>.json 200 bare-array + count(*)
151
+ // companion live-verified. High-value B2G federal datasets (carrier/company
152
+ // census, transportation infrastructure & stats, public health).
153
+ // NOTE (m3/under-index): the FEDERATED aggregator (api.us.socrata.com)
154
+ // under-indexes these hosts (DOT: 3 federated vs 1,873 host-scoped). As of
155
+ // loop cycle 8, `socrata_discover_datasets` WITH a domain uses each host's
156
+ // OWN catalog (search_context) → complete per-host discovery; only the
157
+ // all-host search (domain omitted) still relies on the federated aggregator
158
+ // (its `_meta` note discloses the gap). ──────────────────────────────────
159
+ "data.transportation.gov", // US DOT — e.g. az4n-8mr2 (Company Census File, ~4.47M carriers)
160
+ "data.cdc.gov", // US CDC — e.g. 9bhg-hcku (Provisional COVID-19 Deaths)
161
+ "data.bts.gov", // US BTS (DOT) — e.g. keg4-3bc2 (Border Crossing Entry Data)
162
+ // ── SLED procurement bid-catalog tier (loop cycle 11, 2026-07-19; from the
163
+ // exhaustive US state+local bid-site research). All .gov; host-scoped catalog
164
+ // + /resource/<4x4>.json 200 bare-array live-verified. Distinctive value:
165
+ // these carry LIVE bid-cycle data (bid tabulations / anticipated
166
+ // solicitations), not only award/spend. (★4 other candidate hosts —
167
+ // data.iowa.gov, data.scottsdaleaz.gov, data.gilbertaz.gov, opendata.hawaii.gov
168
+ // — were REJECTED: their /api/catalog/v1 AND /resource endpoints 404 (Next.js/
169
+ // Express apps, not Socrata — a research-agent "Socrata" claim that live
170
+ // verification disproved). Never add a host without a 200 bare-array probe.) ─
171
+ "datacatalog.cookcountyil.gov", // Cook County IL — e.g. 32au-zaqn (Bid Tabulations ~5,607; +awards qh8j-6k63, intent-to-award bgq7-v7ms — 17 procurement datasets)
172
+ "data.illinois.gov", // IL — e.g. 6rb8-ntpm (Future Solicitations = anticipated construction bids); re-included (cycle-1 excluded on federated-discover 0, but host-scoped catalog + /resource verified 2026-07-19)
173
+ "data.cincinnati-oh.gov", // Cincinnati OH (city) — e.g. 2iq3-bugw (Certified Vendors MBE/WBE; +contracts 85xi-xdtw)
174
+ // ── Major-city OFFICIAL portals (loop cycle 5, 2026-07-18). NON-.gov but the
175
+ // unambiguously-official municipal open-data portals for the largest local
176
+ // procurement markets (NYC OpenData / Chicago / DataSF / LA Controller) —
177
+ // host-scoped catalog + /resource 200 bare-array verified. Trust-boundary
178
+ // (like the USAC .org exception): the SSRF guard is the CURATED, FROZEN
179
+ // allowlist, not the TLD; these five are the documented non-.gov entries. ──
180
+ "data.cityofnewyork.us", // NYC OpenData (.us, official) — e.g. dg92-zbpx (City Record: procurement notices)
181
+ "data.cityofchicago.org", // Chicago (.org, official) — e.g. rsxa-ify5 (Contracts)
182
+ "data.sfgov.org", // San Francisco / DataSF (.org, official) — e.g. cqi5-hm2d (Supplier Contracts)
183
+ "controllerdata.lacity.org", // LA City Controller (.org, official) — e.g. pggv-e4fn (Checkbook L.A.)
142
184
  "opendata.usac.org", // USAC E-rate (.org, m6) — e.g. avi8-svp9
185
+ // ── County/city procurement sweep (loop cycle 17, 2026-07-20). Discovered via
186
+ // the Socrata federated catalog (api.us.socrata.com) filtered to procurement/
187
+ // bid/contract datasets, then each host-scoped $select=count(*) + /resource
188
+ // 200 bare-array live-verified. US local govs only (Canada/AU + demo/test +
189
+ // off-theme aggregate hosts filtered out). Mix of official .gov/.org/.com
190
+ // municipal portals (same documented non-.gov trust-boundary as above). ──
191
+ "data.kcmo.org", // Kansas City MO (.org, official) — e.g. 4mdg-usvj (Vendor Payments ~144k; 22 procurement datasets)
192
+ "data.brla.gov", // Baton Rouge / East Baton Rouge Parish LA (.gov) — e.g. e5pk-us93 (Upcoming Procurement Opportunities; 19 procurement datasets)
193
+ "www.dallasopendata.com", // Dallas TX (.com, official) — e.g. x5ih-idh7 (Vendor Payments FY2019–present ~166k)
194
+ "data.lacity.org", // Los Angeles CA (.org, official) — e.g. hf3r-utnq (RAMP Open Bid Opportunities — live bids)
195
+ "data.ramseycountymn.gov", // Ramsey County MN (.gov) — e.g. iu7r-dzmj (Solicitations & Addenda, with due_date/download_url ~516)
196
+ "data.richmondgov.com", // Richmond VA (.com, official) — e.g. xqn7-jvv2 (City Contracts: contract_value/supplier/procurement_type ~1,387)
197
+ // ── County/city procurement sweep, wave 2 (loop cycle 19, 2026-07-20). Same
198
+ // federated-catalog mining + host-scoped count(*) + /resource 200 bare-array
199
+ // verification. All large real checkbook/PO/vendor-payment datasets. ──
200
+ "opendata.howardcountymd.gov", // Howard County MD (.gov) — e.g. mesh-jggc (Vendors Receiving Payments $30k+ ~35k)
201
+ "data.providenceri.gov", // Providence RI (.gov) — e.g. 425y-pm5m (City & School Dept Purchase Orders ~228k)
202
+ "fiscalfocus.pittsburghpa.gov", // Pittsburgh PA (.gov) — e.g. t8t2-4b5n (Checkbook Data ~1.01M)
203
+ "data.coloradosprings.gov", // Colorado Springs CO (.gov) — e.g. yn6y-xikx (Open Checkbook Vendors ~19k)
204
+ "data.framinghamma.gov", // Framingham MA (.gov) — e.g. cqve-ehkr (Checkbook ~324k)
205
+ "data.fultoncountyga.gov", // Fulton County GA (.gov) — e.g. mxhc-krcg (Vendor Payments/disbursements ~217k)
206
+ "atlanta.data.socrata.com", // City of Atlanta GA (Socrata-hosted official portal) — e.g. jmke-icfi (Open Checkbook Ledger ~1.78M)
207
+ "opendata.cityofmesquite.com", // Mesquite TX (.com, official) — e.g. 6tva-azs5 (Check Register ~144k)
208
+ // ── County/city procurement sweep, wave 3 (loop cycle 20, 2026-07-20). Expanded
209
+ // federated-catalog queries (rfp/rfq/disbursement/expenditure/commodity) +
210
+ // offset paging. Provenance confirmed via /api/views attribution or the gov
211
+ // domain itself; hosts with only an individual-name attribution on a generic
212
+ // *.data.socrata.com subdomain (washoe, newcastle) were DEFERRED. ──
213
+ "datahub.usac.org", // USAC E-Rate (.org, same org as opendata.usac.org) — e.g. 39tn-hjzv (E-Rate Open Competitive Bidding, FCC Form 470 — schools/libraries LIVE bids ~2.2M)
214
+ "performance.ci.janesville.wi.us", // City of Janesville WI (.us official municipal domain) — e.g. fd4q-2kma (Open Expenditures Ledger ~1.0M)
215
+ "datahub.austintexas.gov", // Austin TX secondary hub (.gov) — e.g. 3ebq-e9iz (Purchase Order Quantity/Price detail, commodity/goods procurements ~318k)
216
+ "cthru.data.socrata.com", // Commonwealth of Massachusetts, Office of the Comptroller — CTHRU statewide spending (attribution-confirmed) — e.g. kv7m-35wn (Budget/Actual + spending authorizations ~48k)
217
+ "data.macoupincountyil.gov", // Macoupin County IL (.gov) — e.g. wysn-7qcg (Open Expenditures Ledger ~156k)
218
+ "data.oaklandca.gov", // Oakland CA (.gov) — e.g. 4ewt-5m6f (budget expenditures ~66k)
219
+ "data.princegeorgescountymd.gov", // Prince George's County MD (.gov) — e.g. csi4-9jzc (Spending Information: payee_name/agency/amount ~62k)
220
+ "data.cstx.gov", // College Station TX (.gov) — e.g. i5cx-63zy (Open Budget Expenditures ~42k)
221
+ "datahub.transportation.gov", // US DOT secondary hub (.gov) — e.g. 255k-mnvp (Disbursements by States for Highways SF-2 ~15k)
222
+ // ── County/city procurement sweep, wave 4 / tail (loop cycle 21, 2026-07-20).
223
+ // Diminishing returns (most remaining catalog hits are Canada/AU, demo/test,
224
+ // duplicates, or off-theme); these two are the clean US wins. ──
225
+ "citydata.mesaaz.gov", // Mesa AZ secondary hub (.gov, attr "Office of Management and Budget") — e.g. vdg8-dx96 (City Expenditures ~15.4M)
226
+ "data.weho.org", // City of West Hollywood CA (.org, official portal) — e.g. atdr-sk64 (Active Contracts: contract_number/contractor_name/status/type — live/current ~1,030)
143
227
  ] as const;
144
228
 
145
229
  export type SocrataDomain = (typeof SOCRATA_DOMAINS)[number];
@@ -262,6 +346,44 @@ async function getCatalog(params: URLSearchParams): Promise<unknown> {
262
346
  });
263
347
  }
264
348
 
349
+ /**
350
+ * GET a HOST-SCOPED discovery catalog (`https://{domain}/api/catalog/v1?
351
+ * search_context={domain}&…`). Unlike the FEDERATED `getCatalog`
352
+ * (api.us.socrata.com), each portal's OWN catalog COMPLETELY indexes its own
353
+ * datasets — the federated aggregator under-indexes many hosts (loop cycle 8:
354
+ * USAC → 0, DOT → 3 of 1,873). Used whenever a specific `domain` is requested;
355
+ * the all-host search (domain omitted) still needs the federated aggregator.
356
+ * SSRF: identical guard to getSocrataResource — domain ∈ allowlist + the
357
+ * CONSTRUCTED URL's hostname === domain (https); redirect:"error" (B1); the
358
+ * host-only label carries the domain (never the token, m7).
359
+ */
360
+ async function getHostCatalog(
361
+ domain: string,
362
+ params: URLSearchParams,
363
+ ): Promise<unknown> {
364
+ if (!SOCRATA_DOMAIN_SET.has(domain)) {
365
+ throw new ToolErrorCarrier({
366
+ kind: "invalid_input",
367
+ message: `Socrata domain ${JSON.stringify(domain)} is not on the curated allowlist. Allowed: ${SOCRATA_DOMAINS.join(", ")}.`,
368
+ retryable: false,
369
+ });
370
+ }
371
+ const url = `https://${domain}/api/catalog/v1?${params.toString()}`;
372
+ const built = new URL(url);
373
+ if (built.hostname !== domain || built.protocol !== "https:") {
374
+ throw new ToolErrorCarrier({
375
+ kind: "invalid_input",
376
+ message: `Constructed Socrata host-catalog URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted domain ${JSON.stringify(domain)} over https — refusing to fetch (SSRF safety).`,
377
+ retryable: false,
378
+ });
379
+ }
380
+ return getJson(url, {
381
+ label: "socrata:catalog:" + domain,
382
+ headers: appTokenHeader(),
383
+ redirect: "error",
384
+ });
385
+ }
386
+
265
387
  // ─── map + meta helpers ───────────────────────────────────────────
266
388
  const STRING_COERCION_NOTE =
267
389
  "Row value fields arrive as strings verbatim from SODA (e.g. \"13650.00\", \"1\") — parse client-side. A missing value is absent, never 0.";
@@ -464,16 +586,21 @@ function mapCatalogRow(row: unknown): CatalogDataset {
464
586
  }
465
587
 
466
588
  /**
467
- * Discover dataset 4x4 ids via the Socrata catalog (memoized ~10 min). Omitting
468
- * `domain` searches the WHOLE allowlist (repeated `domains=`); passing one scopes
469
- * to it. Returns `[{ id, name, description, domain, updatedAt, link }]` +
470
- * `totalAvailable = resultSetSize`. Feeds `datasetId` to socrata_query.
589
+ * Discover dataset 4x4 ids via the Socrata catalog (memoized ~10 min). Passing a
590
+ * `domain` queries that portal's OWN catalog (search_context) — a COMPLETE
591
+ * per-host index; omitting it searches the WHOLE allowlist via the federated
592
+ * api.us.socrata.com aggregator (repeated `domains=`). Returns `[{ id, name,
593
+ * description, domain, updatedAt, link }]` + `totalAvailable = resultSetSize`.
594
+ * Feeds `datasetId` to socrata_query.
471
595
  *
472
596
  * m3 — this is the catalog's PRIMARY response: a non-number `resultSetSize` is a
473
597
  * hard schema_drift throw (nothing valid to return), never a fabricated total.
474
- * (Note: the catalog does not index every allowlisted host — e.g. USAC — so a
475
- * host may return 0 here yet still be queryable via socrata_query with a known
476
- * 4x4.)
598
+ *
599
+ * UNDER-INDEX (loop cycle 8 fix): the FEDERATED aggregator under-indexes many
600
+ * hosts (USAC → 0; DOT → 3 of 1,873), so the all-host search (domain omitted) can
601
+ * miss datasets — its `_meta` note discloses this. A DOMAIN-scoped search now
602
+ * uses that host's OWN catalog, which indexes it completely (USAC/DOT/etc. fully
603
+ * discoverable). socrata_query works regardless with a known 4x4.
477
604
  */
478
605
  export async function discoverDatasets(args: {
479
606
  q: string;
@@ -485,8 +612,11 @@ export async function discoverDatasets(args: {
485
612
  params.set("q", args.q);
486
613
  params.set("only", "datasets");
487
614
  params.set("limit", String(limit));
615
+ // A specific domain → that portal's OWN catalog (search_context), a COMPLETE
616
+ // per-host index. Omitting domain → the federated aggregator (repeated
617
+ // domains=), the only way to search across all hosts (loop cycle 8).
488
618
  if (args.domain) {
489
- params.append("domains", args.domain);
619
+ params.set("search_context", args.domain);
490
620
  } else {
491
621
  for (const d of SOCRATA_DOMAINS) params.append("domains", d);
492
622
  }
@@ -495,7 +625,9 @@ export async function discoverDatasets(args: {
495
625
  const { totalAvailable, results } = await memoize(
496
626
  key,
497
627
  async () => {
498
- const body = await getCatalog(params);
628
+ const body = args.domain
629
+ ? await getHostCatalog(args.domain, params)
630
+ : await getCatalog(params);
499
631
  const b = (body ?? {}) as { results?: unknown; resultSetSize?: unknown };
500
632
  // m3 — hard drift on the PRIMARY response (contrast the best-effort count).
501
633
  // The check stays INSIDE the memoize callback so a bad shape is never
@@ -517,6 +649,9 @@ export async function discoverDatasets(args: {
517
649
  const scope = args.domain ? `domain ${args.domain}` : `the ${SOCRATA_DOMAINS.length}-host allowlist`;
518
650
  const notes: string[] = [
519
651
  `Catalog search over ${scope} (only=datasets). Feed a result's id to socrata_query as datasetId.`,
652
+ args.domain
653
+ ? `Source: the ${args.domain} portal's OWN catalog (search_context) — a COMPLETE per-host index.`
654
+ : `Source: the federated ${CATALOG_HOST} aggregator, which UNDER-INDEXES some hosts (e.g. USAC, DOT) — pass a specific domain to use that host's own complete catalog.`,
520
655
  `App token: ${appTokenPresent() ? "present (X-App-Token sent; value never logged)" : "absent (keyless)"}.`,
521
656
  ];
522
657
  if (totalAvailable !== null && returned < totalAvailable) {
@@ -528,7 +663,7 @@ export async function discoverDatasets(args: {
528
663
  return withMeta(
529
664
  { query: args.q, domain: args.domain ?? null, results },
530
665
  {
531
- source: `${CATALOG_HOST} catalog ${SOURCE_SUFFIX}`,
666
+ source: `${args.domain ?? CATALOG_HOST} catalog ${SOURCE_SUFFIX}`,
532
667
  keylessMode: true,
533
668
  returned,
534
669
  totalAvailable,