@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
package/src/nhtsa.ts CHANGED
@@ -19,16 +19,20 @@
19
19
  * entirely — never surfaced, logged, or stored. The B2G value is the
20
20
  * manufacturer / component / safety signal, NOT the VIN.
21
21
  *
22
- * ★ THE HONESTY PILLARS (P1-P4, live-verified 2026-07-15):
22
+ * ★ THE HONESTY PILLARS (P1/P3/P4 live-verified 2026-07-15; P2 corrected +
23
+ * re-verified 2026-07-20):
23
24
  * P1: totalAvailable = `Count` (recalls) / `count` (complaints) — the REAL total.
24
25
  * NHTSA returns the COMPLETE filtered set (no pagination), so in the normal
25
26
  * case Count === results.length ⇒ complete:true. totalAvailable is NEVER
26
27
  * fabricated: a PRESENT numeric Count is trusted verbatim; a MISSING Count
27
28
  * falls back to results.length WITH an honest note (never invented).
28
- * P2: results:[] (Count 0) ⇒ an HONEST EMPTY (returned:0, complete:true) — a bad
29
- * make/model that returns 200+Count 0 is an honest no-match, NOT an error. A
30
- * 4xx ⇒ invalid_input; a 5xx/timeout ⇒ THROW (never a fake empty); a 200
31
- * non-JSON body ⇒ schema_drift.
29
+ * P2: ★NHTSA returns HTTP 400 (NOT 200) + {Count/count:0, results:[]} (Message
30
+ * "Results returned successfully") for a VALID make/model/year that simply
31
+ * has ZERO records (live-verified 2026-07-20). getNhtsa reads the body and
32
+ * reclassifies THAT idiom as an HONEST EMPTY (returned:0, totalAvailable:0,
33
+ * complete:true) with a note; any OTHER 400 ⇒ invalid_input, a 404 ⇒
34
+ * not_found, a 5xx/timeout ⇒ THROW (never a fake empty), a 200 non-JSON
35
+ * body ⇒ schema_drift.
32
36
  * P3: booleans (crash/fire/parkIt/parkOutSide/overTheAirUpdate) preserved AS
33
37
  * booleans (a non-boolean ⇒ null, never a fabricated false); counts
34
38
  * (numberOfInjuries/numberOfDeaths) via `num` (a genuine 0 stays 0, NEVER
@@ -42,8 +46,8 @@
42
46
  * so a `../` or `%` can never reach the fixed path).
43
47
  */
44
48
 
45
- import { ToolErrorCarrier } from "./errors.js";
46
- import { getJson, driftError } from "./datasource.js";
49
+ import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
50
+ import { driftError, isRedirectError } from "./datasource.js";
47
51
  import { num, str } from "./coerce.js";
48
52
  import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
49
53
 
@@ -109,10 +113,31 @@ function validateVehicleArgs(args: NhtsaVehicleArgs, label: string): void {
109
113
 
110
114
  // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
111
115
  /**
112
- * GET one NHTSA JSON resource on the FIXED host. Builds
113
- * `https://api.nhtsa.gov${path}?${params}`, asserts the CONSTRUCTED URL's
114
- * hostname === the fixed host over https (belt-and-suspenders), and sets
115
- * `redirect:"error"` (fail closed on any off-host 3xx). Keyless — no header/token.
116
+ * ★ NHTSA's "400-for-empty" idiom. A VALID make/model/year that simply has ZERO
117
+ * records returns HTTP 400 with a body `{Count/count:0, ..., results:[]}` and
118
+ * `Message/message:"Results returned successfully"` (live-verified 2026-07-20:
119
+ * `make=tesla&model=model 3&modelYear=2015` → HTTP 400, Count 0). Recalls use
120
+ * `Count`/`Message`, complaints use lowercase `count`/`message` — accept either.
121
+ * This is a genuine no-match, NOT a bad request; the caller reclassifies it to an
122
+ * honest empty. (A 400 that is NOT this idiom stays a real invalid_input.)
123
+ */
124
+ function isNhtsaEmptyIdiom(body: unknown): boolean {
125
+ if (body === null || typeof body !== "object") return false;
126
+ const b = body as Record<string, unknown>;
127
+ const rawCount = b.Count ?? b.count;
128
+ return num(rawCount) === 0 && Array.isArray(b.results) && b.results.length === 0;
129
+ }
130
+
131
+ /**
132
+ * GET one NHTSA JSON resource on the FIXED host. Bespoke `fetch` (NOT the shared
133
+ * getJson) because we must READ a non-2xx body: NHTSA returns HTTP 400 for a valid
134
+ * query with zero records (the ★400-for-empty idiom above), and getJson would
135
+ * throw invalid_input and DISCARD the body — turning "zero recalls" (a valid,
136
+ * high-value answer) into a phantom "Bad request". Asserts the constructed URL's
137
+ * hostname === the fixed host over https (belt-and-suspenders) + `redirect:"error"`
138
+ * (fail closed on any off-host 3xx). Keyless — no header/token. Non-2xx that is NOT
139
+ * the empty idiom keeps the standard taxonomy (400→invalid_input, 404→not_found,
140
+ * 429→rate_limited, 5xx→upstream_unavailable); a 200 non-JSON body ⇒ schema_drift.
116
141
  */
117
142
  async function getNhtsa(
118
143
  path: string,
@@ -129,7 +154,57 @@ async function getNhtsa(
129
154
  upstreamEndpoint: label,
130
155
  });
131
156
  }
132
- return getJson(url, { label, redirect: "error" });
157
+ let res: Response;
158
+ try {
159
+ res = await fetch(built.toString(), {
160
+ redirect: "error",
161
+ signal: AbortSignal.timeout(15_000),
162
+ });
163
+ } catch (e) {
164
+ if (isRedirectError(e)) {
165
+ throw driftError(
166
+ label,
167
+ `NHTSA returned an off-host redirect (redirect:"error") while fetching ${label} — refusing to follow it (SSRF safety).`,
168
+ );
169
+ }
170
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
171
+ throw new ToolErrorCarrier({
172
+ kind: "upstream_unavailable",
173
+ retryable: false,
174
+ message: `Request to ${label} timed out.`,
175
+ upstreamEndpoint: label,
176
+ });
177
+ }
178
+ throw new ToolErrorCarrier({
179
+ kind: "upstream_unavailable",
180
+ retryable: true,
181
+ retryAfterSeconds: 30,
182
+ message: `Network error reaching ${label}: ${e instanceof Error ? e.message : String(e)}`,
183
+ upstreamEndpoint: label,
184
+ });
185
+ }
186
+ if (!res.ok) {
187
+ // ★P2 CRUX: read the body — a 400 that is NHTSA's empty idiom is an HONEST
188
+ // no-match (returned via resolveTotal as Count 0 → total 0), NOT an error.
189
+ let body: unknown = null;
190
+ try {
191
+ body = await res.json();
192
+ } catch {
193
+ body = null;
194
+ }
195
+ if (res.status === 400 && isNhtsaEmptyIdiom(body)) return body;
196
+ // Any other non-2xx keeps the standard taxonomy (never a fabricated empty).
197
+ // errorFromResponse returns a plain ToolError → wrap it in the carrier to throw.
198
+ throw new ToolErrorCarrier(errorFromResponse(res, label));
199
+ }
200
+ try {
201
+ return await res.json();
202
+ } catch {
203
+ throw driftError(
204
+ label,
205
+ `NHTSA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
206
+ );
207
+ }
133
208
  }
134
209
 
135
210
  /** Build the shared make/model/modelYear query (module-built; no raw passthrough). */
@@ -142,27 +217,17 @@ function vehicleParams(args: NhtsaVehicleArgs): URLSearchParams {
142
217
  }
143
218
 
144
219
  /**
145
- * Fetch + parse a NHTSA resource, mirroring datagov-catalog's catch-ladder: a
146
- * ToolErrorCarrier (host-assert / 4xx-5xx taxonomy) rethrows FIRST (preserving its
147
- * kind); a 200 non-JSON `.json()` SyntaxError reclassifies to schema_drift; a bare
148
- * error rethrows LAST.
220
+ * Build the vehicle query and fetch through getNhtsa, which fully classifies the
221
+ * response: the ★400-for-empty idiom → an honest empty; every other non-2xx → the
222
+ * standard taxonomy; a 200 non-JSON body → schema_drift. A thin wrapper — getNhtsa
223
+ * owns the error contract.
149
224
  */
150
225
  async function fetchNhtsa(
151
226
  path: string,
152
227
  label: string,
153
228
  args: NhtsaVehicleArgs,
154
229
  ): Promise<unknown> {
155
- try {
156
- return await getNhtsa(path, label, vehicleParams(args));
157
- } catch (e) {
158
- if (e instanceof ToolErrorCarrier) throw e;
159
- if (e instanceof SyntaxError)
160
- throw driftError(
161
- label,
162
- `NHTSA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
163
- );
164
- throw e;
165
- }
230
+ return await getNhtsa(path, label, vehicleParams(args));
166
231
  }
167
232
 
168
233
  /**
@@ -46,8 +46,10 @@ export type NistControl = {
46
46
  id: string; // display form, e.g. "AC-2"
47
47
  family: string; // e.g. "AC — Access Control"
48
48
  title: string;
49
- statement: string; // assembled requirement prose (labelled, multi-line)
49
+ status: string | null; // OSCAL status prop, e.g. "withdrawn"; null when active
50
+ statement: string | null; // requirement prose; NULL when absent (a WITHDRAWN control has none) — never ""
50
51
  guidance: string | null; // discussion prose
52
+ incorporatedInto: string[]; // withdrawn control ⇒ the control id(s) it was folded into (e.g. ["AC-2","AU-6"])
51
53
  enhancements: { id: string; title: string }[]; // e.g. AC-2(1)
52
54
  };
53
55
 
@@ -60,6 +62,8 @@ type OscalPart = {
60
62
  type OscalControl = {
61
63
  id?: string;
62
64
  title?: string;
65
+ props?: { name?: string; value?: string }[];
66
+ links?: { href?: string; rel?: string }[];
63
67
  parts?: OscalPart[];
64
68
  controls?: OscalControl[];
65
69
  };
@@ -100,12 +104,26 @@ function partProse(control: OscalControl, name: string): string | null {
100
104
  }
101
105
 
102
106
  function mapControl(control: OscalControl, family: string): NistControl {
107
+ const status =
108
+ (control.props ?? []).find((p) => p.name === "status")?.value?.trim() || null;
109
+ // A withdrawn control carries `links[] rel="incorporated-into"` → the control(s)
110
+ // it was folded into (e.g. AC-13 → AC-2, AU-6). Surface them so a withdrawn
111
+ // control is never mistaken for an active requirement.
112
+ const incorporatedInto = (control.links ?? [])
113
+ .filter((l) => l.rel === "incorporated-into" && typeof l.href === "string")
114
+ .map((l) => displayId(String(l.href).replace(/^#/, "")))
115
+ .filter((x) => x.length > 0);
103
116
  return {
104
117
  id: displayId(control.id ?? ""),
105
118
  family,
106
119
  title: (control.title ?? "").trim(),
107
- statement: partProse(control, "statement") ?? "",
120
+ status,
121
+ // [P3] absent statement ⇒ null, NEVER "" — a withdrawn control has no
122
+ // requirement text; "" would misread as "an active control with a blank
123
+ // requirement".
124
+ statement: partProse(control, "statement"),
108
125
  guidance: partProse(control, "guidance"),
126
+ incorporatedInto,
109
127
  enhancements: (control.controls ?? []).map((e) => ({
110
128
  id: displayId(e.id ?? ""),
111
129
  title: (e.title ?? "").trim(),
@@ -119,7 +137,12 @@ function mapControl(control: OscalControl, family: string): NistControl {
119
137
  * families (else driftError — a truncated catalog is NEVER a fake empty). Enhancements
120
138
  * are carried on each control (not indexed as top-level entries).
121
139
  */
122
- async function loadControls(): Promise<NistControl[]> {
140
+ type OscalMetadata = { version: string | null; lastModified: string | null };
141
+
142
+ async function loadControls(): Promise<{
143
+ controls: NistControl[];
144
+ metadata: OscalMetadata;
145
+ }> {
123
146
  return memoize(
124
147
  "nist:sp800-53r5",
125
148
  async () => {
@@ -131,7 +154,12 @@ async function loadControls(): Promise<NistControl[]> {
131
154
  label: OSCAL_LABEL,
132
155
  redirect: "error",
133
156
  timeoutMs: OSCAL_TIMEOUT_MS,
134
- })) as { catalog?: { groups?: unknown } };
157
+ })) as {
158
+ catalog?: {
159
+ groups?: unknown;
160
+ metadata?: { version?: unknown; "last-modified"?: unknown };
161
+ };
162
+ };
135
163
  const groups = body.catalog?.groups;
136
164
  if (!Array.isArray(groups) || groups.length < FAMILY_FLOOR) {
137
165
  throw driftError(
@@ -139,12 +167,21 @@ async function loadControls(): Promise<NistControl[]> {
139
167
  `OSCAL catalog.groups missing or implausibly small (${Array.isArray(groups) ? groups.length : "not-an-array"} < ${FAMILY_FLOOR} families) — treating as schema drift / truncation, never a fake-empty catalog.`,
140
168
  );
141
169
  }
170
+ // [P5 freshness] The OSCAL catalog is a moving static file on the `main`
171
+ // branch — surface its exact version + last-modified so a compliance caller
172
+ // knows WHICH point-release (5.1.1 / 5.2.0 …) they are reading.
173
+ const md = body.catalog?.metadata ?? {};
174
+ const metadata: OscalMetadata = {
175
+ version: typeof md.version === "string" ? md.version : null,
176
+ lastModified:
177
+ typeof md["last-modified"] === "string" ? md["last-modified"] : null,
178
+ };
142
179
  const controls: NistControl[] = [];
143
180
  for (const g of groups as OscalGroup[]) {
144
181
  const family = `${(g.id ?? "").toUpperCase()} — ${(g.title ?? "").trim()}`;
145
182
  for (const c of g.controls ?? []) controls.push(mapControl(c, family));
146
183
  }
147
- return controls;
184
+ return { controls, metadata };
148
185
  },
149
186
  OSCAL_CACHE_TTL_MS,
150
187
  );
@@ -166,7 +203,7 @@ export async function searchControls(args: {
166
203
  }): Promise<MetaBundle> {
167
204
  const limit = args.limit ?? 25;
168
205
  const offset = args.offset ?? 0;
169
- const all = await loadControls();
206
+ const { controls: all, metadata } = await loadControls();
170
207
 
171
208
  const filtersApplied: string[] = [];
172
209
  const idQ = args.controlId !== undefined ? displayId(args.controlId) : undefined;
@@ -189,7 +226,7 @@ export async function searchControls(args: {
189
226
  // Search the title + requirement statement AND each enhancement's title, so a
190
227
  // term that lives only in an enhancement (e.g. "multi-factor" → IA-2(1)) still
191
228
  // surfaces the parent control. filtersApplied still lists 'keyword'.
192
- const hay = `${c.title}\n${c.statement}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
229
+ const hay = `${c.title}\n${c.statement ?? ""}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
193
230
  if (!hay.includes(kwQ)) return false;
194
231
  }
195
232
  return true;
@@ -201,10 +238,22 @@ export async function searchControls(args: {
201
238
  const hasMore = offset + returned < totalAvailable;
202
239
  const nextOffset = hasMore ? offset + returned : null;
203
240
 
241
+ const versionLabel = metadata.version ? `Rev ${metadata.version}` : "Rev 5";
242
+ const notes: string[] = [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, REFERENCE_NOTE];
243
+ notes.push(
244
+ `OSCAL catalog ${metadata.version ? `version ${metadata.version}` : "revision 5"}${metadata.lastModified ? `, last-modified ${metadata.lastModified.slice(0, 10)}` : ""} — fetched live from the usnistgov/oscal-content \`main\` branch (a MOVING target; control text can change between point releases, e.g. 5.1.1 → 5.2.0). Cite the version above, not just "Rev 5".`,
245
+ );
246
+ const withdrawnOnPage = page.filter((c) => c.status === "withdrawn").length;
247
+ if (withdrawnOnPage > 0) {
248
+ notes.push(
249
+ `${withdrawnOnPage} of the returned control(s) are WITHDRAWN (status:"withdrawn") — a withdrawn control has NO requirement text (statement:null) and is NOT an active requirement; see its incorporatedInto for the control(s) that superseded it.`,
250
+ );
251
+ }
252
+
204
253
  return withMeta(
205
254
  { controls: page },
206
255
  {
207
- source: "NIST SP 800-53 Rev 5 (OSCAL catalog, keyless)",
256
+ source: `NIST SP 800-53 ${versionLabel} (OSCAL catalog, keyless)`,
208
257
  keylessMode: true,
209
258
  returned,
210
259
  totalAvailable,
@@ -213,7 +262,7 @@ export async function searchControls(args: {
213
262
  filtersDropped: [],
214
263
  fieldsUnavailable: [],
215
264
  pagination: { offset, limit, hasMore, nextOffset },
216
- notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, REFERENCE_NOTE],
265
+ notes,
217
266
  } satisfies Partial<ResponseMeta>,
218
267
  );
219
268
  }
package/src/ofac.ts CHANGED
@@ -206,7 +206,18 @@ export function classifyMatch(
206
206
  const allQinE = qt.length > 0 && qt.every((t) => eSet.has(t));
207
207
  const allEinQ = et.length > 0 && et.every((t) => qSet.has(t));
208
208
  if (allQinE || allEinQ) return "strong";
209
- if (entryNorm.includes(queryNorm) || queryNorm.includes(entryNorm)) {
209
+ // Substring containment is "strong" ONLY when the CONTAINED (shorter) string is
210
+ // a substantial fragment. Without a floor, a tiny sub-word substring graded
211
+ // "strong" ("TS" ⊂ "WIDGETS", "IBB" ⊂ "QUIBBLEFARB", "D" ⊂ "WIDGETS") —
212
+ // overstating confidence and flooding nearly every input with false "strong"
213
+ // matches (alert fatigue). Whole-token containment is already handled above by
214
+ // allQinE/allEinQ; this mid-word tier is for the space-stripped joined form and
215
+ // must clear the same ≥3 bar the token "weak" tier uses — here a slightly higher
216
+ // floor since a contiguous substring is a weaker signal than a shared whole token.
217
+ if (
218
+ Math.min(queryNorm.length, entryNorm.length) >= 5 &&
219
+ (entryNorm.includes(queryNorm) || queryNorm.includes(entryNorm))
220
+ ) {
210
221
  return "strong";
211
222
  }
212
223
  for (const t of qt) {
package/src/opengov.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * opengov.ts — OpenGov Procurement (formerly ProcureNow) public solicitations,
3
+ * a keyless-first SLED bid source (loop cycle — SLED bid campaign, 2026-07-19).
4
+ *
5
+ * WHAT IT ADDS: OpenGov Procurement hosts the live open-solicitation portals of
6
+ * **525+ US state/local governments** (cities, counties, school & special
7
+ * districts across 42 states + DC). The public portal SPA
8
+ * (`procurement.opengov.com/portal/{code}`) is served by an UNAUTHENTICATED
9
+ * backend REST API — the official key-gated `api-key` API is NOT used; this
10
+ * consumes ONLY the anonymous endpoints the public portal itself calls, so it is
11
+ * genuinely keyless (live-verified 2026-07-19: bare anonymous request, no api-key /
12
+ * auth header / cookie). It is the OpenGov sibling of the Socrata/CKAN/ArcGIS
13
+ * discovery tools and one of the two highest-reach keyless SLED bid feeds (with
14
+ * Bonfire RSS).
15
+ *
16
+ * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
17
+ * (GET + POST/JSON via ADR-0014, redirect:"error") / `driftError` / `num`·`str`
18
+ * (coerce.ts, null-never-0 / null-never-empty) / `withMeta`·`buildMeta`,
19
+ * mirroring datagov-catalog.ts. KEYLESS: no key seam.
20
+ *
21
+ * ★ SSRF: the host is a compile-time literal (`OPENGOV_HOST`). All inputs ride in
22
+ * a MODULE-BUILT JSON body / URLSearchParams from validated typed args — NO
23
+ * raw-host/raw-path passthrough (this is a FIXED-host API). A post-construction
24
+ * hostname/protocol assertion + `redirect:"error"` lock every call.
25
+ *
26
+ * ★ TWO endpoints (both anonymous, live-verified):
27
+ * - `GET /api/v1/government` → a flat JSON array of ~560 org records (the whole
28
+ * directory in one call; `government.code` = the portal slug). We keep the
29
+ * ACTIVE, non-internal ones (~525 real public portals).
30
+ * - `POST /api/v1/project/list` body `{governmentCode, publicView:true, page,
31
+ * limit}` → `{projects:[…], count}`. `publicView:true` is REQUIRED (the public
32
+ * gate). `count` is the TRUE total of public projects for that org.
33
+ *
34
+ * ★ HONESTY PILLARS (captured live 2026-07-19):
35
+ * P1: totalAvailable = the real total — the filtered directory length (govt
36
+ * list) or the API's `count` (solicitations) — NEVER the page length.
37
+ * P2: getJson→fetchWithRetry THROWS on 429 / 5xx / timeout — NEVER a fake empty.
38
+ * A genuine no-match (projects:[], count:0) ⇒ honest empty.
39
+ * P3: every scalar via `str` (null-never-empty) / `num` (null-never-0). The
40
+ * `status` enum is surfaced VERBATIM (open = currently accepting; the response
41
+ * also carries pending/evaluation/closed — the CONSUMER filters; we do not
42
+ * hide non-open rows or fabricate an "open count").
43
+ * P4: `/government` non-array or `/project/list` `.projects` non-array ⇒
44
+ * driftError; a 200 non-JSON body ⇒ schema_drift via the catch-ladder
45
+ * (ToolErrorCarrier rethrow FIRST so a 429/5xx keeps its taxonomy).
46
+ */
47
+
48
+ import { ToolErrorCarrier } from "./errors.js";
49
+ import { getJson, driftError } from "./datasource.js";
50
+ import { num, str } from "./coerce.js";
51
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
52
+
53
+ // Re-export the shared honesty coercion (num-parity guard).
54
+ export { num };
55
+
56
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
57
+ export const OPENGOV_HOST = "api.procurement.opengov.com";
58
+ const OPENGOV_GOVERNMENT_PATH = "/api/v1/government";
59
+ const OPENGOV_PROJECT_LIST_PATH = "/api/v1/project/list";
60
+ const OPENGOV_GOV_LABEL = "opengov:/api/v1/government";
61
+ const OPENGOV_PROJ_LABEL = "opengov:/api/v1/project/list";
62
+ const OPENGOV_SOURCE = "procurement.opengov.com via OpenGov Procurement public API (keyless)";
63
+ const OPENGOV_PORTAL = "https://procurement.opengov.com/portal";
64
+
65
+ // The org slug grammar (SSRF + injection guard). Real slugs are lowercase
66
+ // alnum + hyphen (e.g. "santacruzca", "u-46", "twc-texas-gov"). Validated
67
+ // BEFORE it rides the JSON body — a "../"/space/uppercase ⇒ invalid_input, 0 fetch.
68
+ export const OPENGOV_CODE_RE = /^[a-z0-9-]{1,64}$/;
69
+
70
+ const OPENGOV_STATUS_NOTE =
71
+ "`status` is surfaced verbatim: open = currently ACCEPTING responses; pending/evaluation/closed are also returned (publicView shows all public projects). Filter status==='open' for live bids. totalAvailable is the org's TOTAL public-project count (all statuses), not an open-only count.";
72
+
73
+ // ─── The curated shapes ───────────────────────────────────────────
74
+ export type OpengovGovernment = {
75
+ code: string | null;
76
+ name: string | null;
77
+ city: string | null;
78
+ state: string | null;
79
+ website: string | null;
80
+ };
81
+
82
+ export type OpengovSolicitation = {
83
+ id: number | null;
84
+ title: string | null;
85
+ solicitationNumber: string | null; // financialId
86
+ status: string | null;
87
+ type: string | null;
88
+ department: string | null;
89
+ releaseDate: string | null;
90
+ proposalDeadline: string | null; // close/due date
91
+ contactName: string | null;
92
+ link: string | null;
93
+ };
94
+
95
+ /** null-preserving boolean (a real true/false survives; else null). */
96
+ function boolOrNull(x: unknown): boolean | null {
97
+ return typeof x === "boolean" ? x : null;
98
+ }
99
+
100
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
101
+ async function getOpengov(
102
+ path: string,
103
+ label: string,
104
+ init: { method?: "POST"; body?: string } = {},
105
+ ): Promise<unknown> {
106
+ const url = `https://${OPENGOV_HOST}${path}`;
107
+ const built = new URL(url);
108
+ if (built.hostname !== OPENGOV_HOST || built.protocol !== "https:") {
109
+ throw new ToolErrorCarrier({
110
+ kind: "invalid_input",
111
+ message: `Constructed OpenGov URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(OPENGOV_HOST)} over https — refusing to fetch (SSRF safety).`,
112
+ retryable: false,
113
+ upstreamEndpoint: label,
114
+ });
115
+ }
116
+ const headers: Record<string, string> =
117
+ init.method === "POST" ? { "Content-Type": "application/json" } : {};
118
+ return getJson(url, { label, headers, redirect: "error", ...init });
119
+ }
120
+
121
+ // ─── Tool 1: opengov_list_governments (the directory) ─────────────
122
+ export type OpengovListGovernmentsArgs = {
123
+ state?: string;
124
+ query?: string;
125
+ limit?: number;
126
+ offset?: number;
127
+ };
128
+
129
+ /**
130
+ * List the OpenGov Procurement government portals (the whole directory arrives in
131
+ * ONE keyless GET). Client-side filters: `state` (2-letter), `query` (name
132
+ * substring). Only ACTIVE, non-internal portals are kept. Feeds `code` to
133
+ * opengov_search_solicitations. totalAvailable = the filtered directory size (P1).
134
+ */
135
+ export async function listGovernments(
136
+ args: OpengovListGovernmentsArgs,
137
+ ): Promise<MetaBundle> {
138
+ const limit = args.limit ?? 50;
139
+ const offset = args.offset ?? 0;
140
+
141
+ let body: unknown;
142
+ try {
143
+ body = await getOpengov(OPENGOV_GOVERNMENT_PATH, OPENGOV_GOV_LABEL);
144
+ } catch (e) {
145
+ if (e instanceof ToolErrorCarrier) throw e;
146
+ if (e instanceof SyntaxError)
147
+ throw driftError(OPENGOV_GOV_LABEL, "OpenGov /government returned a non-JSON body at HTTP 200 — schema drift.");
148
+ throw e;
149
+ }
150
+ // P4: the response MUST be an array (a missing/object body is drift).
151
+ if (!Array.isArray(body)) {
152
+ throw driftError(OPENGOV_GOV_LABEL, "OpenGov /government shape drift — response must be a JSON array of governments.");
153
+ }
154
+
155
+ const stateFilter = args.state ? args.state.trim().toUpperCase() : null;
156
+ const queryFilter = args.query ? args.query.trim().toLowerCase() : null;
157
+ const filtersApplied: string[] = [];
158
+ if (stateFilter) filtersApplied.push("state");
159
+ if (queryFilter) filtersApplied.push("query");
160
+
161
+ const all: OpengovGovernment[] = [];
162
+ for (const row of body as unknown[]) {
163
+ const r = (row ?? {}) as Record<string, unknown>;
164
+ // Keep only active, non-internal, non-vendor real public portals.
165
+ if (boolOrNull(r.isActive) === false) continue;
166
+ if (boolOrNull(r.isInternal) === true) continue;
167
+ const gov = (r.government ?? {}) as Record<string, unknown>;
168
+ const g: OpengovGovernment = {
169
+ code: str(gov.code),
170
+ name: str(r.name),
171
+ city: str(r.city),
172
+ state: str(r.state),
173
+ website: str(r.website),
174
+ };
175
+ if (g.code === null) continue; // no portal slug ⇒ not queryable
176
+ if (stateFilter && (g.state ?? "").toUpperCase() !== stateFilter) continue;
177
+ if (queryFilter && !(g.name ?? "").toLowerCase().includes(queryFilter)) continue;
178
+ all.push(g);
179
+ }
180
+
181
+ const totalAvailable = all.length;
182
+ const page = all.slice(offset, offset + limit);
183
+ const returned = page.length;
184
+ const hasMore = offset + returned < totalAvailable;
185
+ const nextOffset = hasMore ? offset + returned : null;
186
+
187
+ const notes: string[] = [
188
+ "The full OpenGov Procurement directory is returned in one keyless call and filtered client-side; totalAvailable is the exact filtered portal count. Feed a result's `code` to opengov_search_solicitations.",
189
+ ];
190
+ if (filtersApplied.length === 0) {
191
+ notes.push("No filters applied — this lists ALL active OpenGov portals. Add `state` and/or `query` to scope.");
192
+ }
193
+
194
+ return withMeta(
195
+ { governments: page },
196
+ {
197
+ source: OPENGOV_SOURCE,
198
+ keylessMode: true,
199
+ returned,
200
+ totalAvailable,
201
+ filtersApplied,
202
+ filtersDropped: [],
203
+ fieldsUnavailable: [],
204
+ pagination: { offset, limit, hasMore, nextOffset },
205
+ notes,
206
+ } satisfies Partial<ResponseMeta>,
207
+ );
208
+ }
209
+
210
+ // ─── Tool 2: opengov_search_solicitations (per org) ───────────────
211
+ export type OpengovSearchSolicitationsArgs = {
212
+ governmentCode: string;
213
+ limit?: number;
214
+ offset?: number;
215
+ };
216
+
217
+ /**
218
+ * List an OpenGov government's public solicitations (POST /project/list with the
219
+ * REQUIRED publicView gate). Returns all public projects with `status` surfaced
220
+ * (filter status==='open' for live bids). totalAvailable = the API's `count` (the
221
+ * org's total public-project count — P1, never the page length).
222
+ */
223
+ export async function searchSolicitations(
224
+ args: OpengovSearchSolicitationsArgs,
225
+ ): Promise<MetaBundle> {
226
+ const limit = args.limit ?? 50;
227
+ const offset = args.offset ?? 0;
228
+ const code = args.governmentCode ?? "";
229
+
230
+ // Belt-and-suspenders slug grammar (behind the server's Zod). A bad slug would
231
+ // ride the JSON body and either error or mis-scope — reject pre-fetch (0 fetch).
232
+ if (!OPENGOV_CODE_RE.test(code)) {
233
+ throw new ToolErrorCarrier({
234
+ kind: "invalid_input",
235
+ message: `Invalid OpenGov governmentCode ${JSON.stringify(code)} — expected a lowercase-alnum/hyphen portal slug (≤64 chars) from opengov_list_governments (e.g. 'santacruzca', 'u-46').`,
236
+ retryable: false,
237
+ upstreamEndpoint: OPENGOV_PROJ_LABEL,
238
+ });
239
+ }
240
+
241
+ // The API paginates by 0-based `page` + `limit`. Map our offset → page.
242
+ const page = Math.floor(offset / limit);
243
+ const servedOffset = page * limit;
244
+ const payload = JSON.stringify({ governmentCode: code, publicView: true, page, limit });
245
+
246
+ let body: unknown;
247
+ try {
248
+ body = await getOpengov(OPENGOV_PROJECT_LIST_PATH, OPENGOV_PROJ_LABEL, { method: "POST", body: payload });
249
+ } catch (e) {
250
+ if (e instanceof ToolErrorCarrier) throw e;
251
+ if (e instanceof SyntaxError)
252
+ throw driftError(OPENGOV_PROJ_LABEL, "OpenGov /project/list returned a non-JSON body at HTTP 200 — schema drift.");
253
+ throw e;
254
+ }
255
+
256
+ const b = (body ?? {}) as { projects?: unknown; count?: unknown };
257
+ // P4: `projects` MUST be an array (a missing/object projects is drift).
258
+ if (!Array.isArray(b.projects)) {
259
+ throw driftError(OPENGOV_PROJ_LABEL, "OpenGov /project/list shape drift — response.projects must be an array.");
260
+ }
261
+
262
+ const solicitations: OpengovSolicitation[] = (b.projects as unknown[]).map((row) => {
263
+ const r = (row ?? {}) as Record<string, unknown>;
264
+ const id = num(r.id);
265
+ const codeForLink = code;
266
+ return {
267
+ id,
268
+ title: str(r.title),
269
+ solicitationNumber: str(r.financialId),
270
+ status: str(r.status),
271
+ type: str(r.type),
272
+ department: str(r.departmentName),
273
+ releaseDate: str(r.releaseProjectDate ?? r.postedAt),
274
+ proposalDeadline: str(r.proposalDeadline),
275
+ contactName: [str(r.contactFirstName), str(r.contactLastName)].filter((v) => v !== null).join(" ") || null,
276
+ link: id !== null ? `${OPENGOV_PORTAL}/${codeForLink}/projects/${id}` : null,
277
+ };
278
+ });
279
+ const returned = solicitations.length;
280
+
281
+ // P1: totalAvailable = the API `count` (total public projects), NEVER page length.
282
+ const totalAvailable = num(b.count);
283
+ const hasMore =
284
+ totalAvailable !== null ? servedOffset + returned < totalAvailable : returned >= limit;
285
+ const nextOffset = hasMore ? servedOffset + returned : null;
286
+
287
+ const notes: string[] = [OPENGOV_STATUS_NOTE];
288
+ if (servedOffset !== offset) {
289
+ notes.push(`offset was snapped to the page boundary ${servedOffset} (OpenGov paginates by fixed pages of ${limit}).`);
290
+ }
291
+ if (totalAvailable === null) {
292
+ notes.push("totalAvailable is unknown (the API omitted `count`); completeness inferred from page fullness.");
293
+ }
294
+
295
+ return withMeta(
296
+ { governmentCode: code, solicitations },
297
+ {
298
+ source: OPENGOV_SOURCE,
299
+ keylessMode: true,
300
+ returned,
301
+ totalAvailable,
302
+ filtersApplied: ["governmentCode", "publicView"],
303
+ filtersDropped: [],
304
+ fieldsUnavailable: [],
305
+ pagination: { offset: servedOffset, limit, hasMore, nextOffset },
306
+ notes,
307
+ } satisfies Partial<ResponseMeta>,
308
+ );
309
+ }