@cliwant/mcp-sam-gov 1.14.0 → 1.16.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 (55) hide show
  1. package/README.ja.md +3 -2
  2. package/README.ko.md +3 -2
  3. package/README.md +7 -5
  4. package/dist/arcgis-feature.d.ts.map +1 -1
  5. package/dist/arcgis-feature.js +8 -0
  6. package/dist/arcgis-feature.js.map +1 -1
  7. package/dist/bonfire.d.ts +1 -1
  8. package/dist/bonfire.d.ts.map +1 -1
  9. package/dist/bonfire.js +19 -17
  10. package/dist/bonfire.js.map +1 -1
  11. package/dist/ckan.d.ts +6 -4
  12. package/dist/ckan.d.ts.map +1 -1
  13. package/dist/ckan.js +17 -4
  14. package/dist/ckan.js.map +1 -1
  15. package/dist/data-map.d.ts +43 -0
  16. package/dist/data-map.d.ts.map +1 -0
  17. package/dist/data-map.js +344 -0
  18. package/dist/data-map.js.map +1 -0
  19. package/dist/fpds.d.ts.map +1 -1
  20. package/dist/fpds.js +3 -2
  21. package/dist/fpds.js.map +1 -1
  22. package/dist/gao.d.ts.map +1 -1
  23. package/dist/gao.js +17 -6
  24. package/dist/gao.js.map +1 -1
  25. package/dist/gsa-perdiem.d.ts +5 -0
  26. package/dist/gsa-perdiem.d.ts.map +1 -1
  27. package/dist/gsa-perdiem.js +6 -1
  28. package/dist/gsa-perdiem.js.map +1 -1
  29. package/dist/open-checkbook.d.ts +8 -0
  30. package/dist/open-checkbook.d.ts.map +1 -1
  31. package/dist/open-checkbook.js +9 -1
  32. package/dist/open-checkbook.js.map +1 -1
  33. package/dist/pricing.d.ts +1 -0
  34. package/dist/pricing.d.ts.map +1 -1
  35. package/dist/pricing.js +135 -50
  36. package/dist/pricing.js.map +1 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +76 -24
  39. package/dist/server.js.map +1 -1
  40. package/dist/socrata.d.ts +16 -1
  41. package/dist/socrata.d.ts.map +1 -1
  42. package/dist/socrata.js +76 -1
  43. package/dist/socrata.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/arcgis-feature.ts +8 -0
  46. package/src/bonfire.ts +19 -17
  47. package/src/ckan.ts +17 -4
  48. package/src/data-map.ts +386 -0
  49. package/src/fpds.ts +3 -2
  50. package/src/gao.ts +20 -6
  51. package/src/gsa-perdiem.ts +12 -1
  52. package/src/open-checkbook.ts +26 -3
  53. package/src/pricing.ts +142 -48
  54. package/src/server.ts +88 -23
  55. package/src/socrata.ts +83 -1
package/src/socrata.ts CHANGED
@@ -179,7 +179,8 @@ export const SOCRATA_DOMAINS = [
179
179
  // allowlist, not the TLD; these five are the documented non-.gov entries. ──
180
180
  "data.cityofnewyork.us", // NYC OpenData (.us, official) — e.g. dg92-zbpx (City Record: procurement notices)
181
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)
182
+ "data.sfgov.org", // San Francisco / DataSF — PERMANENTLY MIGRATED to data.sf.gov (301 all paths, 2026-09); kept allowlisted so requests reach the migration handler (see MIGRATED_SOCRATA_HOSTS below) instead of returning invalid_input. Use data.sf.gov for all new calls.
183
+ "data.sf.gov", // San Francisco / DataSF (canonical post-migration .gov domain) — e.g. cqi5-hm2d (Supplier Contracts 49,186 rows), hmh3-ff63 (Airport/SFO Contract Opportunities)
183
184
  "controllerdata.lacity.org", // LA City Controller (.org, official) — e.g. pggv-e4fn (Checkbook L.A.)
184
185
  "opendata.usac.org", // USAC E-rate (.org, m6) — e.g. avi8-svp9
185
186
  // ── County/city procurement sweep (loop cycle 17, 2026-07-20). Discovered via
@@ -203,6 +204,7 @@ export const SOCRATA_DOMAINS = [
203
204
  "data.coloradosprings.gov", // Colorado Springs CO (.gov) — e.g. yn6y-xikx (Open Checkbook Vendors ~19k)
204
205
  "data.framinghamma.gov", // Framingham MA (.gov) — e.g. cqve-ehkr (Checkbook ~324k)
205
206
  "data.fultoncountyga.gov", // Fulton County GA (.gov) — e.g. mxhc-krcg (Vendor Payments/disbursements ~217k)
207
+ "sharefulton.fultoncountyga.gov", // Fulton County GA (.gov, second portal) — e.g. kp4p-scak (Vendor Payments 226,797 rows 2014–present, updated 2026-09-14, attribution: "Fulton County Government (GA)")
206
208
  "atlanta.data.socrata.com", // City of Atlanta GA (Socrata-hosted official portal) — e.g. jmke-icfi (Open Checkbook Ledger ~1.78M)
207
209
  "opendata.cityofmesquite.com", // Mesquite TX (.com, official) — e.g. 6tva-azs5 (Check Register ~144k)
208
210
  // ── County/city procurement sweep, wave 3 (loop cycle 20, 2026-07-20). Expanded
@@ -230,6 +232,24 @@ export type SocrataDomain = (typeof SOCRATA_DOMAINS)[number];
230
232
 
231
233
  const SOCRATA_DOMAIN_SET: ReadonlySet<string> = new Set(SOCRATA_DOMAINS);
232
234
 
235
+ /**
236
+ * Known host migrations (permanent 301 redirects). A domain here has permanently
237
+ * moved; redirect:"error" means any fetch to the old host will fail immediately.
238
+ * Rather than returning a retryable upstream_unavailable (which implies the
239
+ * problem may resolve on its own), we pre-flight check this map and return
240
+ * invalid_input — non-retryable, naming the replacement — so the caller can
241
+ * update the `domain` parameter instead of spinning on a doomed retry loop.
242
+ *
243
+ * Keep the old host in SOCRATA_DOMAINS so it passes the allowlist gate (invalid
244
+ * domains are rejected before reaching this check). Only add an entry here when
245
+ * the migration is confirmed by a live 301 probe with a stable redirect_url.
246
+ *
247
+ * data.sfgov.org → data.sf.gov (confirmed 2026-09-21; all paths 301)
248
+ */
249
+ export const MIGRATED_SOCRATA_HOSTS: ReadonlyMap<string, string> = new Map([
250
+ ["data.sfgov.org", "data.sf.gov"],
251
+ ]);
252
+
233
253
  const CATALOG_HOST = "api.us.socrata.com";
234
254
  const CATALOG_URL = `https://${CATALOG_HOST}/api/catalog/v1`;
235
255
 
@@ -297,6 +317,18 @@ async function getSocrataResource(
297
317
  retryable: false,
298
318
  });
299
319
  }
320
+ // Migration pre-flight (MIGRATED_SOCRATA_HOSTS): if the domain has permanently
321
+ // moved, redirect:"error" means any fetch will immediately fail with a 301.
322
+ // Return invalid_input (non-retryable) now — before wasting a network round-trip
323
+ // — naming the replacement so the caller can update the domain parameter.
324
+ const migratedTo = MIGRATED_SOCRATA_HOSTS.get(domain);
325
+ if (migratedTo !== undefined) {
326
+ throw new ToolErrorCarrier({
327
+ kind: "invalid_input",
328
+ message: `Socrata host ${JSON.stringify(domain)} has permanently moved to ${JSON.stringify(migratedTo)}. Update the \`domain\` parameter to ${JSON.stringify(migratedTo)} — retrying ${JSON.stringify(domain)} will not succeed.`,
329
+ retryable: false,
330
+ });
331
+ }
300
332
  if (datasetId.length !== 9 || !DATASET_ID_RE.test(datasetId)) {
301
333
  throw new ToolErrorCarrier({
302
334
  kind: "invalid_input",
@@ -375,6 +407,15 @@ async function getHostCatalog(
375
407
  retryable: false,
376
408
  });
377
409
  }
410
+ // Migration pre-flight — same policy as getSocrataResource (see comment there).
411
+ const migratedTo = MIGRATED_SOCRATA_HOSTS.get(domain);
412
+ if (migratedTo !== undefined) {
413
+ throw new ToolErrorCarrier({
414
+ kind: "invalid_input",
415
+ message: `Socrata host ${JSON.stringify(domain)} has permanently moved to ${JSON.stringify(migratedTo)}. Update the \`domain\` parameter to ${JSON.stringify(migratedTo)} — retrying ${JSON.stringify(domain)} will not succeed.`,
416
+ retryable: false,
417
+ });
418
+ }
378
419
  const url = `https://${domain}/api/catalog/v1?${params.toString()}`;
379
420
  const built = new URL(url);
380
421
  if (built.hostname !== domain || built.protocol !== "https:") {
@@ -419,6 +460,12 @@ async function fetchCount(
419
460
  const params = new URLSearchParams();
420
461
  if (where) params.set("$where", where);
421
462
  if (q) params.set("$q", q);
463
+ // ★ MUST stay count(*) — DO NOT change to count(1) or SELECT count(1).
464
+ // Cloudflare-fronted Socrata hosts (e.g. opendata.maryland.gov) deterministically
465
+ // 403 ("Just a moment...") on count(1) and `$query=SELECT count(1)` — their WAF
466
+ // treats the pattern as SQLi-like — while count(*) consistently returns 200.
467
+ // Verified 3/3 on opendata.maryland.gov (2026-09-21): count(1)→403, count(*)→200
468
+ // (342 rows). Maryland is not broken; the shape here is load-bearing.
422
469
  params.set("$select", "count(*)");
423
470
  let body: unknown;
424
471
  try {
@@ -547,6 +594,22 @@ export async function query(args: {
547
594
  );
548
595
  }
549
596
 
597
+ // ── Response-side aggregate nudge (§A-nudge) ──
598
+ // When the result is truncated (hasMore) and the caller used no aggregate select,
599
+ // the agent only sees a PAGE of rows — NOT the total. Nudge toward an aggregate
600
+ // query so the agent does not try to sum one page manually.
601
+ if (!isAggregateSelect && hasMore) {
602
+ // Find the most likely amount-like column from the first row's keys.
603
+ const firstRow = rows[0];
604
+ const amountKey =
605
+ firstRow !== undefined
606
+ ? (Object.keys(firstRow).find((k) => /amount|amt|total|paid|payment/i.test(k)) ?? "<amount column>")
607
+ : "<amount column>";
608
+ notes.push(
609
+ `This is a PAGE of raw rows (truncated — more rows exist). Do NOT sum this page to get a total. For a grand total: re-query with select='sum(${amountKey})' plus a where filter for vendor/fiscal year. For a top-N ranking: select='vendor_name, sum(${amountKey}) as total' with order='total DESC'. These are aggregate queries — the result is the final answer, not another page.`,
610
+ );
611
+ }
612
+
550
613
  return withMeta(
551
614
  { domain: args.domain, datasetId: args.datasetId, rows },
552
615
  {
@@ -666,6 +729,25 @@ export async function discoverDatasets(args: {
666
729
  `Showing ${returned} of ${totalAvailable} matches; raise limit (≤100) or narrow q for the rest.`,
667
730
  );
668
731
  }
732
+ // HONESTY (false-zero guard). An empty catalog result is easy to read as "this
733
+ // portal has no such data", and the per-host note above even calls the index
734
+ // COMPLETE — but the catalog behaves as if EVERY q term must match, and datasets
735
+ // rarely repeat their own jurisdiction's name. Measured 2026-09-21 on
736
+ // data.illinois.gov: q="Illinois state solicitations" -> 0 and
737
+ // q="Illinois procurement" -> 0, while q="solicitations" -> 1 (6rb8-ntpm). An eval
738
+ // agent took that zero at face value and told the user no Illinois procurement
739
+ // datasets exist. So a zero is reported as possibly false, with the fix.
740
+ if (returned === 0) {
741
+ // Only "more than one word?" matters here, so test for it directly rather than
742
+ // splitting: a raw whitespace split is reserved for disclosure tokenizing
743
+ // (tokenizeForDisclosure, ADR-0022) and this is not a disclosure.
744
+ const multiWord = /\S\s+\S/.test(args.q);
745
+ notes.push(
746
+ multiWord
747
+ ? `0 matches for a multi-word q — likely a FALSE zero, not proof the data is absent: the catalog behaves as if every term must match, and datasets rarely repeat their jurisdiction's name in their title. Put the place in \`domain\` (not in q) and retry with ONE topical term, e.g. 'solicitations', 'bids', 'contract', 'vendor', 'payments', 'purchase'.`
748
+ : `0 matches for q=${JSON.stringify(args.q)} — this is not proof the data is absent: publishers title the same thing differently. Retry with a synonym ('solicitations', 'bids', 'contract', 'vendor', 'payments', 'purchase') before concluding the portal has none.`,
749
+ );
750
+ }
669
751
 
670
752
  return withMeta(
671
753
  { query: args.q, domain: args.domain ?? null, results },