@cliwant/mcp-sam-gov 1.10.0 → 1.12.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 (120) hide show
  1. package/README.ja.md +5 -5
  2. package/README.ko.md +5 -5
  3. package/README.md +23 -7
  4. package/dist/bls.d.ts.map +1 -1
  5. package/dist/bls.js +6 -1
  6. package/dist/bls.js.map +1 -1
  7. package/dist/bonfire.d.ts.map +1 -1
  8. package/dist/bonfire.js +17 -2
  9. package/dist/bonfire.js.map +1 -1
  10. package/dist/cbp-border.d.ts +9 -5
  11. package/dist/cbp-border.d.ts.map +1 -1
  12. package/dist/cbp-border.js +31 -11
  13. package/dist/cbp-border.js.map +1 -1
  14. package/dist/census-economic.d.ts.map +1 -1
  15. package/dist/census-economic.js +10 -1
  16. package/dist/census-economic.js.map +1 -1
  17. package/dist/ckan.d.ts.map +1 -1
  18. package/dist/ckan.js +17 -3
  19. package/dist/ckan.js.map +1 -1
  20. package/dist/clinicaltrials.d.ts.map +1 -1
  21. package/dist/clinicaltrials.js +23 -6
  22. package/dist/clinicaltrials.js.map +1 -1
  23. package/dist/fdic.js +7 -7
  24. package/dist/fdic.js.map +1 -1
  25. package/dist/fema.d.ts.map +1 -1
  26. package/dist/fema.js +13 -1
  27. package/dist/fema.js.map +1 -1
  28. package/dist/gao.d.ts.map +1 -1
  29. package/dist/gao.js +25 -0
  30. package/dist/gao.js.map +1 -1
  31. package/dist/grants.d.ts.map +1 -1
  32. package/dist/grants.js +14 -4
  33. package/dist/grants.js.map +1 -1
  34. package/dist/gsa-csv.d.ts +9 -0
  35. package/dist/gsa-csv.d.ts.map +1 -1
  36. package/dist/gsa-csv.js +37 -8
  37. package/dist/gsa-csv.js.map +1 -1
  38. package/dist/gsa-perdiem.d.ts +16 -2
  39. package/dist/gsa-perdiem.d.ts.map +1 -1
  40. package/dist/gsa-perdiem.js +25 -4
  41. package/dist/gsa-perdiem.js.map +1 -1
  42. package/dist/integrity.d.ts.map +1 -1
  43. package/dist/integrity.js +18 -3
  44. package/dist/integrity.js.map +1 -1
  45. package/dist/lda.d.ts.map +1 -1
  46. package/dist/lda.js +17 -4
  47. package/dist/lda.js.map +1 -1
  48. package/dist/nhtsa.d.ts +9 -5
  49. package/dist/nhtsa.d.ts.map +1 -1
  50. package/dist/nhtsa.js +90 -26
  51. package/dist/nhtsa.js.map +1 -1
  52. package/dist/nist-controls.d.ts +3 -1
  53. package/dist/nist-controls.d.ts.map +1 -1
  54. package/dist/nist-controls.js +34 -12
  55. package/dist/nist-controls.js.map +1 -1
  56. package/dist/nppes.d.ts.map +1 -1
  57. package/dist/nppes.js +17 -4
  58. package/dist/nppes.js.map +1 -1
  59. package/dist/nsf.d.ts.map +1 -1
  60. package/dist/nsf.js +9 -0
  61. package/dist/nsf.js.map +1 -1
  62. package/dist/ofac.d.ts.map +1 -1
  63. package/dist/ofac.js +10 -1
  64. package/dist/ofac.js.map +1 -1
  65. package/dist/openfda-device.d.ts.map +1 -1
  66. package/dist/openfda-device.js +8 -5
  67. package/dist/openfda-device.js.map +1 -1
  68. package/dist/openfda-drugsfda.d.ts.map +1 -1
  69. package/dist/openfda-drugsfda.js +8 -5
  70. package/dist/openfda-drugsfda.js.map +1 -1
  71. package/dist/openfda.d.ts +21 -0
  72. package/dist/openfda.d.ts.map +1 -1
  73. package/dist/openfda.js +38 -4
  74. package/dist/openfda.js.map +1 -1
  75. package/dist/opengov.d.ts.map +1 -1
  76. package/dist/opengov.js +8 -4
  77. package/dist/opengov.js.map +1 -1
  78. package/dist/sam-gov/client.d.ts.map +1 -1
  79. package/dist/sam-gov/client.js +13 -4
  80. package/dist/sam-gov/client.js.map +1 -1
  81. package/dist/server.d.ts +11 -0
  82. package/dist/server.d.ts.map +1 -1
  83. package/dist/server.js +79 -10
  84. package/dist/server.js.map +1 -1
  85. package/dist/socrata.d.ts +1 -1
  86. package/dist/socrata.d.ts.map +1 -1
  87. package/dist/socrata.js +44 -7
  88. package/dist/socrata.js.map +1 -1
  89. package/dist/usaspending.d.ts +2 -2
  90. package/dist/usaspending.d.ts.map +1 -1
  91. package/dist/usaspending.js +21 -10
  92. package/dist/usaspending.js.map +1 -1
  93. package/package.json +11 -3
  94. package/src/bls.ts +8 -1
  95. package/src/bonfire.ts +17 -2
  96. package/src/cbp-border.ts +30 -9
  97. package/src/census-economic.ts +13 -2
  98. package/src/ckan.ts +19 -3
  99. package/src/clinicaltrials.ts +25 -8
  100. package/src/fdic.ts +7 -7
  101. package/src/fema.ts +15 -1
  102. package/src/gao.ts +27 -0
  103. package/src/grants.ts +16 -3
  104. package/src/gsa-csv.ts +38 -7
  105. package/src/gsa-perdiem.ts +28 -4
  106. package/src/integrity.ts +18 -3
  107. package/src/lda.ts +21 -5
  108. package/src/nhtsa.ts +92 -27
  109. package/src/nist-controls.ts +58 -9
  110. package/src/nppes.ts +18 -4
  111. package/src/nsf.ts +10 -0
  112. package/src/ofac.ts +12 -1
  113. package/src/openfda-device.ts +10 -4
  114. package/src/openfda-drugsfda.ts +10 -4
  115. package/src/openfda.ts +47 -5
  116. package/src/opengov.ts +8 -4
  117. package/src/sam-gov/client.ts +13 -4
  118. package/src/server.ts +86 -10
  119. package/src/socrata.ts +44 -7
  120. package/src/usaspending.ts +21 -10
@@ -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
@@ -106,7 +106,7 @@ import { realpathSync } from "node:fs";
106
106
  const SERVER_NAME = "mcp-sam-gov";
107
107
  // Kept in lockstep with package.json / manifest.json / server.json.
108
108
  // Keep in sync with package.json "version" (asserted at release; see CHANGELOG).
109
- const SERVER_VERSION = "1.10.0";
109
+ const SERVER_VERSION = "1.12.0";
110
110
 
111
111
  // ─── Tool input schemas (Zod) ────────────────────────────────────
112
112
 
@@ -3759,7 +3759,7 @@ const CensusBusinessPatternsInput = z.object({
3759
3759
  .regex(/^\d{4}$/)
3760
3760
  .optional()
3761
3761
  .describe(
3762
- "The CBP data year (default '2022', the latest confirmed vintage). Validated ^\\d{4}$ (it rides in the request path).",
3762
+ "The CBP data year (default '2023', the latest published vintage — CBP is released with a ~2-year lag). Validated ^\\d{4}$ (it rides in the request path).",
3763
3763
  ),
3764
3764
  limit: z
3765
3765
  .number()
@@ -4418,7 +4418,7 @@ const GsaPerdiemRatesInput = z
4418
4418
  .regex(/^\d{4}$/)
4419
4419
  .optional()
4420
4420
  .describe(
4421
- "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).",
4422
4422
  ),
4423
4423
  })
4424
4424
  .describe(
@@ -4543,7 +4543,7 @@ const LdaSearchFilingsInput = z.object({
4543
4543
  .string()
4544
4544
  .min(1)
4545
4545
  .optional()
4546
- .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."),
4547
4547
  issue: z
4548
4548
  .string()
4549
4549
  .min(1)
@@ -4895,6 +4895,21 @@ export const TOOLS: ToolDef[] = [
4895
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.",
4896
4896
  );
4897
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
+ }
4898
4913
  notes.push(...enrichmentNotes);
4899
4914
 
4900
4915
  // freshness is surfaced structurally in `data` (the ResponseMeta type
@@ -5654,7 +5669,7 @@ export const TOOLS: ToolDef[] = [
5654
5669
  defineTool({
5655
5670
  name: "nist_800_53_controls",
5656
5671
  description:
5657
- "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').",
5658
5673
  inputSchema: NistControlsInput,
5659
5674
  handler: (input) => nistControls.searchControls(input),
5660
5675
  }),
@@ -6269,7 +6284,7 @@ export const TOOLS: ToolDef[] = [
6269
6284
  defineTool({
6270
6285
  name: "census_business_patterns",
6271
6286
  description:
6272
- "Market sizing by NAICS × geography — establishments, employment, and annual payroll from the US Census County Business Patterns (CBP) API (api.census.gov/data/{year}/cbp). ★REQUIRES a free CENSUS_API_KEY: the Census Data API has NO keyless tier, so without the key this tool THROWS an honest config error (get one at https://api.census.gov/data/key_signup.html; call api_key_status to see every source's key requirement). Input: optional `naics` (2–6 digit NAICS-2017, e.g. '5415'; omit to aggregate all sectors), `geography` (us|state|county, default us; county REQUIRES `state`), `state` (2-digit FIPS, e.g. '06'), `year` (default '2022'), optional `limit` (client-side top-N; CBP has no server pagination). Returns { rows:[{ name, geoId, naicsCode, naicsLabel, establishments, employees, annualPayrollUsd, state }] } + honest _meta. HONESTY: establishments/employees are integer counts and annualPayrollUsd is annual US dollars (×1000 from the source's $1,000-unit PAYANN); large-negative suppression sentinels map to null — NEVER a negative number and NEVER 0 (a genuine 0 stays 0; note CBP primarily uses noise-infusion + suppression flags, surfaced as reported — see the tool's suppression note); geoId/naicsCode/state are STRINGS (leading zeros survive). CBP returns the COMPLETE geography set for the filter (no pagination) ⇒ totalAvailable = the row count, complete:true. A missing/invalid key ⇒ invalid_input (a 302 to the Missing-Key page); a header-only body ⇒ honest empty (returned:0); a 5xx ⇒ THROWS; a 200 non-JSON ⇒ schema_drift. The key rides ONLY in the &key= query param — never logged or echoed.",
6287
+ "Market sizing by NAICS × geography — establishments, employment, and annual payroll from the US Census County Business Patterns (CBP) API (api.census.gov/data/{year}/cbp). ★REQUIRES a free CENSUS_API_KEY: the Census Data API has NO keyless tier, so without the key this tool THROWS an honest config error (get one at https://api.census.gov/data/key_signup.html; call api_key_status to see every source's key requirement). Input: optional `naics` (2–6 digit NAICS-2017, e.g. '5415'; omit to aggregate all sectors), `geography` (us|state|county, default us; county REQUIRES `state`), `state` (2-digit FIPS, e.g. '06'), `year` (default '2023'), optional `limit` (client-side top-N; CBP has no server pagination). Returns { rows:[{ name, geoId, naicsCode, naicsLabel, establishments, employees, annualPayrollUsd, state }] } + honest _meta. HONESTY: establishments/employees are integer counts and annualPayrollUsd is annual US dollars (×1000 from the source's $1,000-unit PAYANN); large-negative suppression sentinels map to null — NEVER a negative number and NEVER 0 (a genuine 0 stays 0; note CBP primarily uses noise-infusion + suppression flags, surfaced as reported — see the tool's suppression note); geoId/naicsCode/state are STRINGS (leading zeros survive). CBP returns the COMPLETE geography set for the filter (no pagination) ⇒ totalAvailable = the row count, complete:true. A missing/invalid key ⇒ invalid_input (a 302 to the Missing-Key page); a header-only body ⇒ honest empty (returned:0); a 5xx ⇒ THROWS; a 200 non-JSON ⇒ schema_drift. The key rides ONLY in the &key= query param — never logged or echoed.",
6273
6288
  inputSchema: CensusBusinessPatternsInput,
6274
6289
  handler: (input) => censusEconomic.businessPatterns(input),
6275
6290
  }),
@@ -6446,7 +6461,7 @@ export const TOOLS: ToolDef[] = [
6446
6461
  defineTool({
6447
6462
  name: "cbp_border_wait_times",
6448
6463
  description:
6449
- "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).",
6450
6465
  inputSchema: CbpBorderWaitInput,
6451
6466
  handler: (input) => cbpBorder.borderWaitTimes(input),
6452
6467
  }),
@@ -6475,7 +6490,7 @@ export const TOOLS: ToolDef[] = [
6475
6490
  defineTool({
6476
6491
  name: "gsa_perdiem_rates",
6477
6492
  description:
6478
- "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).",
6479
6494
  inputSchema: GsaPerdiemRatesInput,
6480
6495
  handler: (input) => gsaPerdiem.perdiemRates(input),
6481
6496
  }),
@@ -6510,7 +6525,7 @@ export const TOOLS: ToolDef[] = [
6510
6525
  defineTool({
6511
6526
  name: "lda_search_filings",
6512
6527
  description:
6513
- "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).",
6514
6529
  inputSchema: LdaSearchFilingsInput,
6515
6530
  handler: (input) => lda.searchFilings(input),
6516
6531
  }),
@@ -6618,6 +6633,7 @@ async function main() {
6618
6633
  name: t.name,
6619
6634
  description: t.description,
6620
6635
  inputSchema: zodToJsonSchema(t.inputSchema),
6636
+ annotations: toolAnnotations(t.name),
6621
6637
  })),
6622
6638
  };
6623
6639
  });
@@ -6793,10 +6809,57 @@ export async function runTool(
6793
6809
  throw new Error(`Unknown tool: ${name}`);
6794
6810
  }
6795
6811
 
6812
+ // ─── MCP tool annotations ────────────────────────────────────────────────────
6813
+ // Every tool advertises a human-friendly `title` plus behaviour hints, per the MCP
6814
+ // spec and the Anthropic Connectors Directory requirement (title + readOnlyHint).
6815
+ // EVERY tool in this server is strictly READ-ONLY (queries public data; none mutate
6816
+ // upstream state) and OPEN-WORLD (calls external government/public APIs), so those
6817
+ // two hints are uniform. `title` is derived from the tool name via a source-prefix
6818
+ // map so e.g. `sam_search_opportunities` → "SAM.gov: Search Opportunities".
6819
+ const TOOL_SOURCE_LABELS: Record<string, string> = {
6820
+ sam: "SAM.gov", usas: "USAspending", edgar: "SEC EDGAR", fdic: "FDIC", cms: "CMS",
6821
+ treasury: "Treasury", regulations: "Regulations.gov", openfda: "openFDA",
6822
+ govinfo: "GovInfo", fema: "FEMA", far: "FAR/DFARS", ecfr: "eCFR", census: "Census",
6823
+ clinicaltrials: "ClinicalTrials.gov", bls: "BLS", socrata: "Socrata", opengov: "OpenGov",
6824
+ nsf: "NSF", nonprofit: "Nonprofit", nhtsa: "NHTSA", gsa: "GSA", grants: "Grants.gov",
6825
+ fred: "FRED", fac: "FAC", echo: "EPA ECHO", dol: "DOL", congress: "Congress.gov",
6826
+ ckan: "data.gov", bonfire: "Bonfire", arcgis: "ArcGIS", sba: "SBA", ofac: "OFAC",
6827
+ nws: "NWS", nppes: "NPPES", nist: "NIST", nih: "NIH", lda: "Senate LDA", hts: "USITC HTS",
6828
+ bea: "BEA", cbp: "CBP", cpsc: "CPSC", nvd: "NVD", courtlistener: "CourtListener",
6829
+ fpds: "FPDS", gao: "GAO", epa: "EPA", nsn: "NSN",
6830
+ };
6831
+ const TOOL_ACRONYMS = new Set([
6832
+ "naics", "psc", "cfda", "npi", "cve", "csv", "id", "url", "rss", "api", "hts", "sic",
6833
+ "dmepos", "cbp", "sam", "fdic", "epa", "ofac", "far", "dfars", "irs", "usc", "cfr",
6834
+ ]);
6835
+ export function humanizeToolTitle(name: string): string {
6836
+ const tokens = name.split("_");
6837
+ // `fed_register_*` is a two-token source label.
6838
+ let source = tokens[0] ?? "";
6839
+ let rest = tokens.slice(1);
6840
+ if (source === "fed" && tokens[1] === "register") {
6841
+ source = "fed_register";
6842
+ rest = tokens.slice(2);
6843
+ }
6844
+ const label = source === "fed_register" ? "Federal Register" : TOOL_SOURCE_LABELS[source];
6845
+ const words = (label ? rest : tokens)
6846
+ .map((w) => (TOOL_ACRONYMS.has(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
6847
+ .join(" ");
6848
+ return label ? (words ? `${label}: ${words}` : label) : words;
6849
+ }
6850
+ /** Uniform MCP annotations for a tool: derived title + read-only / open-world hints. */
6851
+ export function toolAnnotations(name: string): {
6852
+ title: string;
6853
+ readOnlyHint: true;
6854
+ openWorldHint: true;
6855
+ } {
6856
+ return { title: humanizeToolTitle(name), readOnlyHint: true, openWorldHint: true };
6857
+ }
6858
+
6796
6859
  /**
6797
6860
  * Hand-rolled Zod → JSON Schema converter (subset we use).
6798
6861
  */
6799
- function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
6862
+ export function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
6800
6863
  const def = (schema as unknown as { _def: { typeName: string } })._def;
6801
6864
  const tn = def.typeName;
6802
6865
  const description = (schema as unknown as { description?: string }).description;
@@ -6848,6 +6911,19 @@ function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
6848
6911
  const innerSchema = zodToJsonSchema(inner);
6849
6912
  return description ? { ...innerSchema, description } : innerSchema;
6850
6913
  }
6914
+ if (tn === "ZodEffects") {
6915
+ // .refine() / .superRefine() / .transform() wrap the REAL schema at
6916
+ // `_def.schema` (used for cross-field rules like "npi OR state required").
6917
+ // Without this branch these fall through to the {type:"string"} default,
6918
+ // publishing a degenerate object-less inputSchema that a schema-driven MCP
6919
+ // client cannot construct a call against — even though the runtime Zod still
6920
+ // demands the full object. Unwrap so the published schema keeps its real
6921
+ // properties / required / enums.
6922
+ const inner = (schema as unknown as { _def: { schema: z.ZodTypeAny } })._def
6923
+ .schema;
6924
+ const innerSchema = zodToJsonSchema(inner);
6925
+ return description ? { ...innerSchema, description } : innerSchema;
6926
+ }
6851
6927
  return { type: "string", ...(description ? { description } : {}) };
6852
6928
  }
6853
6929
 
package/src/socrata.ts CHANGED
@@ -194,6 +194,36 @@ export const SOCRATA_DOMAINS = [
194
194
  "data.lacity.org", // Los Angeles CA (.org, official) — e.g. hf3r-utnq (RAMP Open Bid Opportunities — live bids)
195
195
  "data.ramseycountymn.gov", // Ramsey County MN (.gov) — e.g. iu7r-dzmj (Solicitations & Addenda, with due_date/download_url ~516)
196
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)
197
227
  ] as const;
198
228
 
199
229
  export type SocrataDomain = (typeof SOCRATA_DOMAINS)[number];
@@ -208,14 +238,21 @@ const CATALOG_URL = `https://${CATALOG_HOST}/api/catalog/v1`;
208
238
  // regex `$` alone would admit ("abcd-1234\n" passes /…$/ in JS).
209
239
  const DATASET_ID_RE = /^[a-z0-9]{4}-[a-z0-9]{4}$/;
210
240
 
211
- // D2 — an AGGREGATE $select projection. Matches a SoQL aggregate function
212
- // (count/sum/avg/min/max) applied via `fn(` — the `\b…\s*\(` shape avoids false
213
- // hits on column names like `max_temperature` (no paren) or `xmax(` (no word
214
- // boundary) — OR an explicit `group by`/`$group`. Case-insensitive. When the
215
- // caller's own $select is aggregate, the count(*) companion is skipped (its
216
- // raw-row total would be false for aggregate result rows). See `query`.
241
+ // D2 — a cardinality-CHANGING $select projection: ANY function call (`word(` —
242
+ // count/sum/avg/min/max/median/stddev/count_distinct/percentile/…), the bare
243
+ // `distinct` keyword, or an explicit `group by`/`$group`. Case-insensitive. The
244
+ // `\b\w+\s*\(` shape matches a real function CALL (a word followed by a paren),
245
+ // never a plain column name like `max_temperature` (no paren). When the caller's
246
+ // own $select is one of these, the result rows are NOT raw records, so the
247
+ // count(*) companion (which counts SOURCE rows) would be a FALSE total — e.g.
248
+ // `$select=distinct state` returns 54 rows but count(*) is 137,700 → a wrong total
249
+ // AND a hasMore livelock (paging forever over empty pages). So we skip the
250
+ // companion (totalAvailable:null + page-fullness hasMore). A plain comma-separated
251
+ // column list is NOT matched and keeps its real count(*) total. (A per-row
252
+ // function like `upper(x)` also matches → a conservative null-total, an honest
253
+ // "unknown" rather than risking a wrong one.) See `query`.
217
254
  const AGGREGATE_SELECT_RE =
218
- /\b(?:count|sum|avg|min|max)\s*\(|\bgroup\s+by\b|\$group\b/i;
255
+ /\b\w+\s*\(|\bdistinct\b|\bgroup\s+by\b|\$group\b/i;
219
256
 
220
257
  // ─── HONESTY-CRITICAL coercions (null, never 0, for absent) ───────
221
258
  // `num`/`str` are the shared, audited null-never-0 coercions in ./coerce.js
@@ -1468,7 +1468,7 @@ export async function searchRecompetes(args: {
1468
1468
  awardId: string;
1469
1469
  generatedInternalId: string;
1470
1470
  incumbent: string;
1471
- amount: number;
1471
+ amount: number | null;
1472
1472
  currentEndDate: string;
1473
1473
  daysUntilCurrentEnd: number;
1474
1474
  potentialEndDate?: string | null;
@@ -1515,8 +1515,11 @@ export async function searchRecompetes(args: {
1515
1515
  pastWindow = true;
1516
1516
  break;
1517
1517
  }
1518
- const amount = row["Award Amount"] ?? 0;
1519
- if (amount < minAwardValue) continue;
1518
+ // Filter/sort on a numeric view (absent = 0 for COMPARISON only), but EMIT the
1519
+ // raw value below so an ABSENT amount stays null — P3 null-never-0, matching every
1520
+ // sibling search tool ("an ABSENT Award Amount → null, NEVER a fabricated $0").
1521
+ const amountNum = row["Award Amount"] ?? 0;
1522
+ if (amountNum < minAwardValue) continue;
1520
1523
  const potentialEnd = includePotentialEnd
1521
1524
  ? row["Period of Performance Potential End Date"] ?? null
1522
1525
  : undefined;
@@ -1529,7 +1532,7 @@ export async function searchRecompetes(args: {
1529
1532
  awardId: row["Award ID"] ?? "",
1530
1533
  generatedInternalId: row.generated_internal_id ?? "",
1531
1534
  incumbent: row["Recipient Name"] ?? "",
1532
- amount,
1535
+ amount: row["Award Amount"] ?? null,
1533
1536
  currentEndDate: end as string,
1534
1537
  daysUntilCurrentEnd: d,
1535
1538
  ...(includePotentialEnd
@@ -1557,7 +1560,10 @@ export async function searchRecompetes(args: {
1557
1560
  results.sort((a, b) => {
1558
1561
  if (a.daysUntilCurrentEnd !== b.daysUntilCurrentEnd)
1559
1562
  return a.daysUntilCurrentEnd - b.daysUntilCurrentEnd;
1560
- if (b.amount !== a.amount) return b.amount - a.amount;
1563
+ // A null amount (P3 — absent, not $0) sorts as 0 for this tiebreak ONLY; it is
1564
+ // never surfaced as 0 in the output.
1565
+ const av = a.amount ?? 0, bv = b.amount ?? 0;
1566
+ if (bv !== av) return bv - av;
1561
1567
  return a.awardId.localeCompare(b.awardId);
1562
1568
  });
1563
1569
 
@@ -1570,10 +1576,15 @@ export async function searchRecompetes(args: {
1570
1576
  // (early-stop fired). If the scan budget truncated, it is unknown → null,
1571
1577
  // and the returned set is a lower bound.
1572
1578
  const totalAvailable = scanTruncated ? null : totalInWindow;
1573
- const nextOffset = startIdx + pageSize;
1574
- const hasMore = scanTruncated
1575
- ? true // more may exist beyond the scanned pages
1576
- : nextOffset < totalInWindow;
1579
+ // `nextOffset` may ONLY advance into rows we ACTUALLY scanned. The un-scanned
1580
+ // remainder (scanTruncated) is NOT offset-reachable: paging past totalInWindow
1581
+ // re-runs the whole bounded scan and returns EMPTY pages forever — the
1582
+ // "re-fetch the SAME page" livelock every other tool in this module emits
1583
+ // nextOffset:null to avoid. So advance the cursor only while more SCANNED rows
1584
+ // remain; signal the un-scanned tail via hasMore:true + nextOffset:null (the
1585
+ // notes already tell the caller to raise scanBudgetPages, not to paginate).
1586
+ const moreInScanned = startIdx + pageSize < totalInWindow;
1587
+ const hasMore = moreInScanned || scanTruncated;
1577
1588
  const truncated = hasMore || scanTruncated;
1578
1589
 
1579
1590
  const notes: string[] = [
@@ -1612,7 +1623,7 @@ export async function searchRecompetes(args: {
1612
1623
  pagination: {
1613
1624
  offset: startIdx,
1614
1625
  limit: pageSize,
1615
- nextOffset: hasMore ? nextOffset : null,
1626
+ nextOffset: moreInScanned ? startIdx + pageSize : null,
1616
1627
  hasMore,
1617
1628
  },
1618
1629
  filtersApplied,