@cliwant/mcp-sam-gov 1.0.0 → 1.2.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.
@@ -9,12 +9,15 @@
9
9
  * egress (an edge/WAF IP-reputation block). This module is that reader; it slots
10
10
  * into the Phase-1 `throughPathChain` as a LOWER-priority `ResiliencePath`.
11
11
  *
12
- * ★INERT BY DEFAULT (the pass/fail bar): the snapshot base URL comes from the
13
- * env var `SAMGOV_SNAPSHOT_BASE_URL`, which is UNSET by default. When unset,
14
- * `snapshotPath()` returns `null` — the path is simply NOT added to a source's
15
- * chain, so every source stays single-path (live-only) and its output is
16
- * byte-identical to today. A snapshot fallback exists ONLY when an operator
17
- * explicitly configures a base URL.
12
+ * ★DEFAULT-ON (resilience active out of the box): the snapshot base URL comes
13
+ * from the env var `SAMGOV_SNAPSHOT_BASE_URL`. When it is UNSET, the reader now
14
+ * resolves to `DEFAULT_SNAPSHOT_BASE_URL` — the public, weekly-refreshed GitHub
15
+ * mirror so every user gets offline fallback with zero configuration. An
16
+ * operator can point at their own mirror (any custom URL) or DISABLE the
17
+ * fallback entirely (pure live-only) with a disable sentinel
18
+ * (`SAMGOV_SNAPSHOT_BASE_URL=off`); when disabled, `snapshotPath()` returns
19
+ * `null` — the path is simply NOT added to a source's chain, so every source
20
+ * stays single-path (live-only) and its output is byte-identical to today.
18
21
  *
19
22
  * ★POLICY BOUNDARY (ADR-0045 §"정책 경계", invariant — mirrors datasource.ts):
20
23
  * • PUBLIC-ONLY (M3/m2): the builder writes ONLY public + redistributable data
@@ -49,20 +52,33 @@ export type SnapshotEnvelope<T = unknown> = {
49
52
  /** Env-driven resilience config. Read at CALL TIME so it is togglable per call. */
50
53
  export type ResilienceConfig = {
51
54
  /**
52
- * The snapshot mirror base URL (no trailing slash), or `undefined` when the
53
- * env var is unset/blank snapshot DISABLED every source stays live-only.
55
+ * The snapshot mirror base URL (no trailing slash) the hosted default when
56
+ * the env var is unset, a custom mirror when set to a URL, or `undefined` when
57
+ * DISABLED via a sentinel (`off`) ⇒ every source stays live-only.
54
58
  */
55
59
  snapshotBaseUrl: string | undefined;
56
60
  };
61
+ /**
62
+ * The public, weekly-refreshed snapshot mirror (see .github/workflows/snapshots.yml);
63
+ * read-only public reference data. This is the DEFAULT base URL when
64
+ * `SAMGOV_SNAPSHOT_BASE_URL` is unset. Disable the fallback with
65
+ * `SAMGOV_SNAPSHOT_BASE_URL=off`.
66
+ */
67
+ export declare const DEFAULT_SNAPSHOT_BASE_URL = "https://raw.githubusercontent.com/cliwant/mcp-sam-gov/snapshots";
57
68
  /**
58
69
  * Resolve `SAMGOV_SNAPSHOT_BASE_URL` at CALL TIME (never cached at module load,
59
70
  * so a test — or an operator flipping the env — takes effect immediately, and so
60
- * importing this module has zero config side effects). Returns `undefined` when
61
- * the var is unset or blank (the INERT default: snapshot disabled). A trailing
62
- * slash is stripped so `${base}/${key}.json` is well-formed.
71
+ * importing this module has zero config side effects). The resolution is
72
+ * DEFAULT-ON:
73
+ * env UNSET `DEFAULT_SNAPSHOT_BASE_URL` (resilience ON by default).
74
+ * • env is a DISABLE sentinel — case-insensitive one of `off` / `none` /
75
+ * `false` / `0` / `disabled`, OR blank after trim ⇒ `undefined` (snapshot
76
+ * disabled = live-only, byte-identical to pre-ADR output).
77
+ * • any other value ⇒ that custom mirror URL, trailing slash stripped so
78
+ * `${base}/${key}.json` is well-formed.
63
79
  */
64
80
  export declare function resolveSnapshotBaseUrl(): string | undefined;
65
- /** The env-driven resilience config (default = snapshot disabled). */
81
+ /** The env-driven resilience config (default = hosted snapshot mirror ON). */
66
82
  export declare function resilienceConfig(): ResilienceConfig;
67
83
  /**
68
84
  * P5 provenance → `_meta` partial (ADR-0045 B2/M1). The SHARED threading helper
@@ -79,7 +95,7 @@ export declare function resilienceConfig(): ResilienceConfig;
79
95
  export declare function provenanceMeta(provenance: Provenance | undefined): Partial<ResponseMeta>;
80
96
  /**
81
97
  * Build a `ResiliencePath` that reads the snapshot for `key` — or `null` when
82
- * the snapshot mirror is not configured (the INERT default).
98
+ * the snapshot mirror is DISABLED (`SAMGOV_SNAPSHOT_BASE_URL=off`).
83
99
  *
84
100
  * When configured, the path fetches `${base}/${key}.json` via the shipped
85
101
  * `getJson` with `redirect:"error"` (off-host redirect ⇒ TypeError ⇒ honest
@@ -89,9 +105,10 @@ export declare function provenanceMeta(provenance: Provenance | undefined): Part
89
105
  * `throughPathChain` reads `path.provenance` AFTER awaiting `run()`, so the
90
106
  * per-fetch `asOf` is captured (mirrors the `{body,provenance}` contract).
91
107
  *
92
- * ★A NULL return is how INERTness is achieved structurally: the Treasury pilot
93
- * builds `[livePath, snapshotPath(key)].filter(Boolean)`, so when this returns
94
- * null the chain is single-entry (live only) ⇒ `throughPathChain` fast-paths ⇒
108
+ * ★A NULL return is how the DISABLED (live-only) path stays byte-identical
109
+ * structurally: the Treasury pilot builds
110
+ * `[livePath, snapshotPath(key)].filter(Boolean)`, so when this returns null the
111
+ * chain is single-entry (live only) ⇒ `throughPathChain` fast-paths ⇒
95
112
  * byte-identical to today.
96
113
  */
97
114
  export declare function snapshotPath<T = unknown>(key: string): ResiliencePath<T> | null;
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAuB,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC5F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,GAAG,OAAO,IAAI;IAC1C,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,IAAI,EAAE,CAAC,CAAC;CACT,CAAC;AAEF,mFAAmF;AACnF,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;OAGG;IACH,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,SAAS,CAK3D;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,IAAI,gBAAgB,CAEnD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,UAAU,GAAG,SAAS,GACjC,OAAO,CAAC,YAAY,CAAC,CAKvB;AAyCD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EACtC,GAAG,EAAE,MAAM,GACV,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI,CAiC1B"}
1
+ {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAuB,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC5F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,GAAG,OAAO,IAAI;IAC1C,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,IAAI,EAAE,CAAC,CAAC;CACT,CAAC;AAEF,mFAAmF;AACnF,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;OAIG;IACH,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,oEAC6B,CAAC;AAYpE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,SAAS,CAO3D;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,IAAI,gBAAgB,CAEnD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,UAAU,GAAG,SAAS,GACjC,OAAO,CAAC,YAAY,CAAC,CAKvB;AAyCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EACtC,GAAG,EAAE,MAAM,GACV,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI,CAiC1B"}
package/dist/snapshot.js CHANGED
@@ -9,12 +9,15 @@
9
9
  * egress (an edge/WAF IP-reputation block). This module is that reader; it slots
10
10
  * into the Phase-1 `throughPathChain` as a LOWER-priority `ResiliencePath`.
11
11
  *
12
- * ★INERT BY DEFAULT (the pass/fail bar): the snapshot base URL comes from the
13
- * env var `SAMGOV_SNAPSHOT_BASE_URL`, which is UNSET by default. When unset,
14
- * `snapshotPath()` returns `null` — the path is simply NOT added to a source's
15
- * chain, so every source stays single-path (live-only) and its output is
16
- * byte-identical to today. A snapshot fallback exists ONLY when an operator
17
- * explicitly configures a base URL.
12
+ * ★DEFAULT-ON (resilience active out of the box): the snapshot base URL comes
13
+ * from the env var `SAMGOV_SNAPSHOT_BASE_URL`. When it is UNSET, the reader now
14
+ * resolves to `DEFAULT_SNAPSHOT_BASE_URL` — the public, weekly-refreshed GitHub
15
+ * mirror so every user gets offline fallback with zero configuration. An
16
+ * operator can point at their own mirror (any custom URL) or DISABLE the
17
+ * fallback entirely (pure live-only) with a disable sentinel
18
+ * (`SAMGOV_SNAPSHOT_BASE_URL=off`); when disabled, `snapshotPath()` returns
19
+ * `null` — the path is simply NOT added to a source's chain, so every source
20
+ * stays single-path (live-only) and its output is byte-identical to today.
18
21
  *
19
22
  * ★POLICY BOUNDARY (ADR-0045 §"정책 경계", invariant — mirrors datasource.ts):
20
23
  * • PUBLIC-ONLY (M3/m2): the builder writes ONLY public + redistributable data
@@ -28,21 +31,46 @@
28
31
  * note + gates `complete` off. A snapshot body can NEVER be labelled live.
29
32
  */
30
33
  import { getJson, driftError } from "./datasource.js";
34
+ /**
35
+ * The public, weekly-refreshed snapshot mirror (see .github/workflows/snapshots.yml);
36
+ * read-only public reference data. This is the DEFAULT base URL when
37
+ * `SAMGOV_SNAPSHOT_BASE_URL` is unset. Disable the fallback with
38
+ * `SAMGOV_SNAPSHOT_BASE_URL=off`.
39
+ */
40
+ export const DEFAULT_SNAPSHOT_BASE_URL = "https://raw.githubusercontent.com/cliwant/mcp-sam-gov/snapshots";
41
+ /** Case-insensitive disable sentinels: any of these (or a blank value) means
42
+ * "snapshot DISABLED = live-only", resolving to `undefined`. */
43
+ const SNAPSHOT_DISABLE_SENTINELS = new Set([
44
+ "off",
45
+ "none",
46
+ "false",
47
+ "0",
48
+ "disabled",
49
+ ]);
31
50
  /**
32
51
  * Resolve `SAMGOV_SNAPSHOT_BASE_URL` at CALL TIME (never cached at module load,
33
52
  * so a test — or an operator flipping the env — takes effect immediately, and so
34
- * importing this module has zero config side effects). Returns `undefined` when
35
- * the var is unset or blank (the INERT default: snapshot disabled). A trailing
36
- * slash is stripped so `${base}/${key}.json` is well-formed.
53
+ * importing this module has zero config side effects). The resolution is
54
+ * DEFAULT-ON:
55
+ * env UNSET `DEFAULT_SNAPSHOT_BASE_URL` (resilience ON by default).
56
+ * • env is a DISABLE sentinel — case-insensitive one of `off` / `none` /
57
+ * `false` / `0` / `disabled`, OR blank after trim ⇒ `undefined` (snapshot
58
+ * disabled = live-only, byte-identical to pre-ADR output).
59
+ * • any other value ⇒ that custom mirror URL, trailing slash stripped so
60
+ * `${base}/${key}.json` is well-formed.
37
61
  */
38
62
  export function resolveSnapshotBaseUrl() {
39
63
  const raw = process.env.SAMGOV_SNAPSHOT_BASE_URL;
40
64
  if (raw === undefined)
65
+ return DEFAULT_SNAPSHOT_BASE_URL;
66
+ const trimmed = raw.trim();
67
+ if (trimmed.length === 0)
68
+ return undefined;
69
+ if (SNAPSHOT_DISABLE_SENTINELS.has(trimmed.toLowerCase()))
41
70
  return undefined;
42
- const trimmed = raw.trim().replace(/\/+$/, "");
43
- return trimmed.length > 0 ? trimmed : undefined;
71
+ return trimmed.replace(/\/+$/, "");
44
72
  }
45
- /** The env-driven resilience config (default = snapshot disabled). */
73
+ /** The env-driven resilience config (default = hosted snapshot mirror ON). */
46
74
  export function resilienceConfig() {
47
75
  return { snapshotBaseUrl: resolveSnapshotBaseUrl() };
48
76
  }
@@ -96,7 +124,7 @@ function parseSnapshotEnvelope(raw, label) {
96
124
  }
97
125
  /**
98
126
  * Build a `ResiliencePath` that reads the snapshot for `key` — or `null` when
99
- * the snapshot mirror is not configured (the INERT default).
127
+ * the snapshot mirror is DISABLED (`SAMGOV_SNAPSHOT_BASE_URL=off`).
100
128
  *
101
129
  * When configured, the path fetches `${base}/${key}.json` via the shipped
102
130
  * `getJson` with `redirect:"error"` (off-host redirect ⇒ TypeError ⇒ honest
@@ -106,15 +134,16 @@ function parseSnapshotEnvelope(raw, label) {
106
134
  * `throughPathChain` reads `path.provenance` AFTER awaiting `run()`, so the
107
135
  * per-fetch `asOf` is captured (mirrors the `{body,provenance}` contract).
108
136
  *
109
- * ★A NULL return is how INERTness is achieved structurally: the Treasury pilot
110
- * builds `[livePath, snapshotPath(key)].filter(Boolean)`, so when this returns
111
- * null the chain is single-entry (live only) ⇒ `throughPathChain` fast-paths ⇒
137
+ * ★A NULL return is how the DISABLED (live-only) path stays byte-identical
138
+ * structurally: the Treasury pilot builds
139
+ * `[livePath, snapshotPath(key)].filter(Boolean)`, so when this returns null the
140
+ * chain is single-entry (live only) ⇒ `throughPathChain` fast-paths ⇒
112
141
  * byte-identical to today.
113
142
  */
114
143
  export function snapshotPath(key) {
115
144
  const base = resolveSnapshotBaseUrl();
116
145
  if (base === undefined)
117
- return null; // INERT: snapshot disabled ⇒ no path.
146
+ return null; // DISABLED (live-only) ⇒ no path.
118
147
  if (!SNAPSHOT_KEY_RE.test(key)) {
119
148
  // A bad key is a programming error, not a runtime data condition — refuse to
120
149
  // construct a path rather than build a URL that could traverse.
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliwant/mcp-sam-gov",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "mcpName": "io.github.cliwant/mcp-sam-gov",
5
5
  "description": "Most comprehensive keyless MCP server for US federal contracting + spending + regulation: SAM.gov, USAspending, Federal Register, eCFR, Grants.gov. 52 tools, no API key, plug into Claude Desktop / Claude Code / Codex CLI / Cursor / Continue / Gemini CLI.",
6
6
  "keywords": [
@@ -0,0 +1,425 @@
1
+ /**
2
+ * US Census — County Business Patterns (CBP) — the MARKET-SIZING lane
3
+ * (ADR-0047, Wave-4 source #1). NAICS × geography establishments / employment /
4
+ * annual payroll — the demand-side complement to BLS-QCEW + USAspending in the
5
+ * B2G market-sizing set.
6
+ *
7
+ * ★ THIS IS THE SERVER'S FIRST KEY-REQUIRED SOURCE. The Census Data API removed
8
+ * its keyless tier — a request WITHOUT a key is 302-redirected to a "Missing
9
+ * Key" HTML page. So, honestly: with NO `CENSUS_API_KEY` this tool THROWS an
10
+ * `invalid_input` config error BEFORE any fetch (never a fake-empty, never a
11
+ * keyless-pretend). The other 111 tools stay keyless — this key is scoped to
12
+ * this one source. (Contrast the OPTIONAL keys of datagov/bls/nvd, which lift a
13
+ * tier but are not required.)
14
+ *
15
+ * DIFFERENT HOST than census.ts (the geocoder, geocoding.geo.census.gov): the
16
+ * DATA API is `api.census.gov/data/{year}/cbp`. This module COPIES (does NOT
17
+ * import) the census.ts fixed-host SSRF idiom (a single host const + a
18
+ * post-construction `new URL().hostname` assertion + `redirect:"error"`) and does
19
+ * NOT touch census_geocode.
20
+ *
21
+ * Coercion/meta code is REUSED (`driftError`, `errorFromResponse`, `num`
22
+ * coerce.ts null-never-0, `withMeta`/`buildMeta`). The ONE bespoke bit is the
23
+ * fetch: a single `fetch(redirect:"manual")` (NOT the shared getJson) so a
24
+ * missing/invalid-key 302 surfaces as an INSPECTABLE opaque-redirect → honest
25
+ * invalid_input, instead of undici's redirect:"error" TypeError that
26
+ * fetchWithRetry would mask as a retryable outage (see the fetch block). The
27
+ * optional-key leak discipline is MIRRORED from datagovKey.ts/bls.ts — but here
28
+ * the key is REQUIRED and rides ONLY in the `&key=` query param, NOWHERE else
29
+ * (never the label, `_meta.source`, notes, or a log — the K-test).
30
+ *
31
+ * GET https://api.census.gov/data/{year}/cbp
32
+ * ?get=NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN,GEO_ID
33
+ * &for=<geoClause> (us:* | state:* | state:NN | county:*)
34
+ * &in=state:NN (county queries only)
35
+ * &NAICS2017=<naics> (optional NAICS filter)
36
+ * &key=<CENSUS_API_KEY> (REQUIRED)
37
+ * → a 2D JSON ARRAY: row 0 = column headers, rows 1..N = data. e.g.
38
+ * [["NAME","ESTAB","EMP","PAYANN","NAICS2017","NAICS2017_LABEL","state"],
39
+ * ["California","39755","...","...","5415","...","06"], ...]
40
+ *
41
+ * ★ HONESTY (ADR-0047 P1–P5):
42
+ * [KEY] no key ⇒ invalid_input THROW pre-fetch (0 fetch); the message names
43
+ * CENSUS_API_KEY + the free-signup URL. A wire 302 (a key that IS set but is
44
+ * invalid → the Missing-Key page) is caught via redirect:"manual" as an
45
+ * opaque-redirect ⇒ invalid_input "check CENSUS_API_KEY" (never a
46
+ * fake-empty, never a masked outage).
47
+ * [P1] CBP returns the COMPLETE geography set for the filter (no server
48
+ * pagination) ⇒ totalAvailable = the row count, complete:true. NEVER
49
+ * fabricated (RED if totalAvailable = header-length or invented).
50
+ * [P3] ★the sentinel→null crux: Census suppresses/withholds cells with large
51
+ * NEGATIVE sentinels (-999999999 / -888888888 / -666666666 …). `censusNum`
52
+ * maps any value ≤ -100000000 to **null** (withheld) — NEVER a negative
53
+ * number, NEVER 0. A genuine 0 stays 0. `annualPayrollUsd = PAYANN×1000`
54
+ * (PAYANN is in $1,000 units), null-preserving.
55
+ * [P2] a 302 ⇒ invalid_input (key); a header-only body ⇒ honest empty
56
+ * (returned:0, complete:true); a 5xx ⇒ upstream_unavailable THROW; a 200
57
+ * non-JSON ⇒ schema_drift.
58
+ * [P4] a body that is not an array, or whose row 0 is not a string[] header
59
+ * row ⇒ driftError (never a fabricated empty).
60
+ * [SSRF] fixed host; `year` re-guarded ^\d{4}$ (it rides in the PATH); `naics`
61
+ * ^\d{2,6}$; `state` ^\d{2}$; geography enum {us,state,county}. All
62
+ * predicate VALUES ride in URLSearchParams. The key rides `&key=` ONLY.
63
+ */
64
+
65
+ import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
66
+ import { driftError } from "./datasource.js";
67
+ import { num, str } from "./coerce.js";
68
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
69
+
70
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
71
+ // ADR-0005 v2 FIX-C) so a num regression fails together across sources. NO local
72
+ // num/str.
73
+ export { num };
74
+
75
+ // ─── SSRF core: the single fixed host (DIFFERENT from census.ts) ──
76
+ const CENSUS_DATA_HOST = "api.census.gov";
77
+ const CENSUS_DATA_LABEL = "census:/data/cbp"; // host-only ToolError surface; NO token, NO key
78
+
79
+ // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
80
+ const YEAR_RE = /^\d{4}$/; // rides in the PATH — strict 4-digit (no path injection)
81
+ const NAICS_RE = /^\d{2,6}$/; // 2–6 digit NAICS-2017 sector/code
82
+ const STATE_FIPS_RE = /^\d{2}$/; // 2-digit state FIPS
83
+ const GEOGRAPHIES = new Set(["us", "state", "county"]);
84
+
85
+ // The Census suppression/withhold sentinel floor. Census encodes a suppressed or
86
+ // unavailable cell as a large NEGATIVE value (-999999999 / -888888888 /
87
+ // -666666666 …). Establishment/employment/payroll counts are non-negative, so any
88
+ // value at/below this floor is a sentinel, NOT data.
89
+ const CENSUS_SENTINEL_FLOOR = -100000000;
90
+
91
+ const DEFAULT_YEAR = "2022"; // the latest confirmed CBP vintage (ADR-0047)
92
+
93
+ // ─── Honesty notes (ADR-0047 required set) ────────────────────────
94
+ const KEY_REQUIRED_NOTE =
95
+ "This source REQUIRES a free CENSUS_API_KEY (the Census Data API has no keyless tier). The key is sent ONLY as the &key= query parameter to api.census.gov and is NEVER logged, echoed, or placed in this response.";
96
+ const PAYROLL_UNITS_NOTE =
97
+ "annualPayrollUsd is ANNUAL payroll in US dollars, converted from the Census PAYANN field's $1,000 units (×1000). establishments and employees are integer counts (as-of the reference year).";
98
+ const SUPPRESSED_NOTE =
99
+ "Census suppresses cells for confidentiality/reliability using large negative sentinels (e.g. -999999999); such values are mapped to null (withheld) — NEVER a negative number and NEVER 0. A genuine 0 is preserved as 0.";
100
+ const NO_PAGINATION_NOTE =
101
+ "CBP returns the COMPLETE set of geographies matching the filter (no server-side pagination); totalAvailable equals the number of rows returned. Narrow with naics / geography to reduce the row count.";
102
+
103
+ // ─── The key seam (REQUIRED; value NEVER leaked past the &key= param) ──
104
+ /** Read CENSUS_API_KEY from env; trim; return the value or undefined (unset/blank). */
105
+ export function censusApiKey(): string | undefined {
106
+ const raw = process.env.CENSUS_API_KEY;
107
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
108
+ return trimmed ? trimmed : undefined;
109
+ }
110
+
111
+ // ─── Curated row shape ────────────────────────────────────────────
112
+ export type CbpRow = {
113
+ name: string | null; // the geography NAME (e.g. "California")
114
+ geoId: string | null; // GEO_ID (a STRING — leading zeros survive)
115
+ naicsCode: string | null; // NAICS2017 (a STRING — sector codes carry structure)
116
+ naicsLabel: string | null; // NAICS2017_LABEL
117
+ establishments: number | null; // ESTAB — null when suppressed (NEVER negative/0-lie)
118
+ employees: number | null; // EMP — null when suppressed
119
+ annualPayrollUsd: number | null; // PAYANN×1000 — null when suppressed
120
+ state: string | null; // state FIPS (a STRING — leading zeros survive)
121
+ };
122
+
123
+ /** num(), but map the Census negative suppression sentinel family → null (withheld). */
124
+ function censusNum(v: unknown): number | null {
125
+ const n = num(v);
126
+ if (n === null) return null;
127
+ // A large-negative sentinel is a WITHHELD/suppressed cell, never data. A genuine
128
+ // 0 (n > floor) passes through as 0.
129
+ if (n <= CENSUS_SENTINEL_FLOOR) return null;
130
+ return n;
131
+ }
132
+
133
+ /** Annual payroll: PAYANN is in $1,000 units → ×1000; null-preserving. */
134
+ function mul1000(n: number | null): number | null {
135
+ return n === null ? null : n * 1000;
136
+ }
137
+
138
+ export type CensusBusinessPatternsArgs = {
139
+ naics?: string;
140
+ geography?: string; // us | state | county (default us)
141
+ state?: string; // 2-digit FIPS (required for county; optional filter for state)
142
+ year?: string; // ^\d{4}$ (default 2022)
143
+ limit?: number; // OPTIONAL client-side top-N slice (CBP has no server pagination)
144
+ };
145
+
146
+ /**
147
+ * Fetch County Business Patterns rows for a NAICS × geography filter → normalized
148
+ * establishment / employment / annual-payroll rows + honest `_meta`. REQUIRES
149
+ * CENSUS_API_KEY (throws invalid_input pre-fetch when unset). The 2D-array body is
150
+ * parsed by HEADER NAME (order-independent); suppressed cells map to null.
151
+ */
152
+ export async function businessPatterns(
153
+ args: CensusBusinessPatternsArgs,
154
+ ): Promise<MetaBundle> {
155
+ // ── [KEY] REQUIRED key — throw an honest config error BEFORE any fetch. ──
156
+ const key = censusApiKey();
157
+ if (key === undefined) {
158
+ throw new ToolErrorCarrier({
159
+ kind: "invalid_input",
160
+ retryable: false,
161
+ message:
162
+ "Census Data API requires a free key. Get one at https://api.census.gov/data/key_signup.html and set CENSUS_API_KEY.",
163
+ upstreamEndpoint: CENSUS_DATA_LABEL,
164
+ });
165
+ }
166
+
167
+ // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
168
+ // a DIRECT handler call bypasses Zod). ──
169
+ const year = args.year ?? DEFAULT_YEAR;
170
+ if (!YEAR_RE.test(year)) {
171
+ throw new ToolErrorCarrier({
172
+ kind: "invalid_input",
173
+ retryable: false,
174
+ message: `Invalid year ${JSON.stringify(year)} — expected a 4-digit year (^\\d{4}$), e.g. "2022". (year rides in the request PATH; it is strictly validated.)`,
175
+ upstreamEndpoint: CENSUS_DATA_LABEL,
176
+ });
177
+ }
178
+
179
+ const geography = args.geography ?? "us";
180
+ if (!GEOGRAPHIES.has(geography)) {
181
+ throw new ToolErrorCarrier({
182
+ kind: "invalid_input",
183
+ retryable: false,
184
+ message: `Invalid geography ${JSON.stringify(geography)} — expected one of us, state, county.`,
185
+ upstreamEndpoint: CENSUS_DATA_LABEL,
186
+ });
187
+ }
188
+
189
+ if (args.state !== undefined && !STATE_FIPS_RE.test(args.state)) {
190
+ throw new ToolErrorCarrier({
191
+ kind: "invalid_input",
192
+ retryable: false,
193
+ message: `Invalid state ${JSON.stringify(args.state)} — expected a 2-digit state FIPS code (^\\d{2}$), e.g. "06" (California).`,
194
+ upstreamEndpoint: CENSUS_DATA_LABEL,
195
+ });
196
+ }
197
+
198
+ if (args.naics !== undefined && !NAICS_RE.test(args.naics)) {
199
+ throw new ToolErrorCarrier({
200
+ kind: "invalid_input",
201
+ retryable: false,
202
+ message: `Invalid naics ${JSON.stringify(args.naics)} — expected a 2–6 digit NAICS-2017 code (^\\d{2,6}$), e.g. "5415" (Computer Systems Design).`,
203
+ upstreamEndpoint: CENSUS_DATA_LABEL,
204
+ });
205
+ }
206
+
207
+ // ── Resolve the geography clause (for=… [+ in=state:…]). ──
208
+ let forClause: string;
209
+ let inClause: string | undefined;
210
+ let geoFilter: string;
211
+ if (geography === "us") {
212
+ forClause = "us:*";
213
+ geoFilter = "geography:us";
214
+ } else if (geography === "state") {
215
+ forClause = args.state !== undefined ? `state:${args.state}` : "state:*";
216
+ geoFilter = `geography:state:${args.state ?? "*"}`;
217
+ } else {
218
+ // county — requires a state (the CBP `in=state:` predicate is mandatory).
219
+ if (args.state === undefined) {
220
+ throw new ToolErrorCarrier({
221
+ kind: "invalid_input",
222
+ retryable: false,
223
+ message:
224
+ "geography 'county' requires `state` (a 2-digit FIPS) — CBP county queries need an `in=state:NN` predicate. Pass state, e.g. state:'06'.",
225
+ upstreamEndpoint: CENSUS_DATA_LABEL,
226
+ });
227
+ }
228
+ forClause = "county:*";
229
+ inClause = `state:${args.state}`;
230
+ geoFilter = `geography:county:* in state:${args.state}`;
231
+ }
232
+
233
+ // ── Build the query (all VALUES via URLSearchParams — no host/path steer; the
234
+ // REQUIRED key rides ONLY here in &key=). ──
235
+ const params = new URLSearchParams();
236
+ params.set("get", "NAME,NAICS2017_LABEL,ESTAB,EMP,PAYANN,GEO_ID");
237
+ params.set("for", forClause);
238
+ if (inClause !== undefined) params.set("in", inClause);
239
+ if (args.naics !== undefined) params.set("NAICS2017", args.naics);
240
+ params.set("key", key);
241
+
242
+ const url = `https://${CENSUS_DATA_HOST}/data/${year}/cbp?${params.toString()}`;
243
+ // Belt-and-suspenders: the fixed host + strictly-validated path leave nothing to
244
+ // steer the authority; assert the built URL cannot have been moved off-host.
245
+ const built = new URL(url);
246
+ if (built.hostname !== CENSUS_DATA_HOST || built.protocol !== "https:") {
247
+ throw new ToolErrorCarrier({
248
+ kind: "invalid_input",
249
+ retryable: false,
250
+ message: `Constructed Census Data URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${CENSUS_DATA_HOST} over https — refusing to fetch (SSRF safety).`,
251
+ upstreamEndpoint: CENSUS_DATA_LABEL,
252
+ });
253
+ }
254
+
255
+ // ── Fetch with redirect:"manual" — the KEY-ERROR DETECTION crux. A missing or
256
+ // invalid key makes the Census Data API 302-redirect to its "Missing Key" page
257
+ // (live-verified: `HTTP 302` + `Location: /data/missing_key.html` +
258
+ // `X-DataWebAPI-KeyError: 1`). We must NOT use redirect:"error" via getJson
259
+ // here: undici rejects an "error"-mode redirect with a TypeError that the
260
+ // shared fetchWithRetry catch reclassifies to a *retryable upstream_unavailable*
261
+ // — masking a key-config error as a transient outage. redirect:"manual" instead
262
+ // yields an INSPECTABLE opaque-redirect (type "opaqueredirect", status 0), so
263
+ // the missing/invalid key surfaces as an honest invalid_input. This is a
264
+ // SINGLE classified attempt (no auto-retry): a transient 5xx THROWS
265
+ // upstream_unavailable — re-invoke the tool to retry. The 5xx/404/400 taxonomy
266
+ // is delegated to the shared errors.ts `errorFromResponse`; a 200 non-JSON body
267
+ // ⇒ r.json() SyntaxError ⇒ schema_drift. The redirect is NEVER followed, so no
268
+ // off-host hop can occur (a stronger SSRF posture than redirect:"error"). ──
269
+ let res: Response;
270
+ try {
271
+ res = await fetch(built.toString(), {
272
+ redirect: "manual",
273
+ signal: AbortSignal.timeout(15_000),
274
+ });
275
+ } catch (e) {
276
+ // Timeout/abort ⇒ non-retryable (the same aborted signal would re-reject);
277
+ // a genuine network TypeError ⇒ retryable upstream_unavailable. NEVER empty.
278
+ if (
279
+ e instanceof Error &&
280
+ (e.name === "TimeoutError" || e.name === "AbortError")
281
+ ) {
282
+ throw new ToolErrorCarrier({
283
+ kind: "upstream_unavailable",
284
+ message: `Request to ${CENSUS_DATA_LABEL} timed out.`,
285
+ retryable: false,
286
+ upstreamEndpoint: CENSUS_DATA_LABEL,
287
+ });
288
+ }
289
+ throw new ToolErrorCarrier({
290
+ kind: "upstream_unavailable",
291
+ message: `Network error reaching ${CENSUS_DATA_LABEL}: ${e instanceof Error ? e.message : String(e)}`,
292
+ retryable: true,
293
+ retryAfterSeconds: 30,
294
+ upstreamEndpoint: CENSUS_DATA_LABEL,
295
+ });
296
+ }
297
+
298
+ // [KEY] A redirect (opaque-redirect via redirect:"manual", or a raw 3xx status) ⇒
299
+ // the missing/invalid-key "Missing Key" page ⇒ an honest key-config error, NEVER
300
+ // a fake-empty (swallowing this as empty ⇒ RED in the fault suite).
301
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
302
+ throw new ToolErrorCarrier({
303
+ kind: "invalid_input",
304
+ retryable: false,
305
+ message:
306
+ "Census Data API redirected the request to its 'Missing Key' page — CENSUS_API_KEY is missing or invalid. Get or check a free key at https://api.census.gov/data/key_signup.html.",
307
+ upstreamEndpoint: CENSUS_DATA_LABEL,
308
+ });
309
+ }
310
+
311
+ // [P2] 404/429/5xx/4xx ⇒ the shared errors.ts taxonomy (a DOWN service is NEVER
312
+ // an empty result; 400 → invalid_input, 5xx → upstream_unavailable, …).
313
+ if (!res.ok) {
314
+ throw new ToolErrorCarrier(errorFromResponse(res, CENSUS_DATA_LABEL));
315
+ }
316
+
317
+ // [P4] 200 ⇒ parse JSON; a non-JSON body (an HTML error page at 200) makes
318
+ // r.json() throw a SyntaxError ⇒ schema_drift (never read as an empty result).
319
+ let body: unknown;
320
+ try {
321
+ body = await res.json();
322
+ } catch (e) {
323
+ if (e instanceof SyntaxError) {
324
+ throw driftError(
325
+ CENSUS_DATA_LABEL,
326
+ "Census CBP returned a non-JSON body at HTTP 200 (likely an HTML 'Missing Key' / error page) — treating as schema drift (never read as an empty result).",
327
+ );
328
+ }
329
+ throw e;
330
+ }
331
+
332
+ // ── [P4] Parse the 2D array: row 0 = string[] header, rows 1..N = data. ──
333
+ if (!Array.isArray(body) || body.length === 0) {
334
+ throw driftError(
335
+ CENSUS_DATA_LABEL,
336
+ "Census CBP returned a body that is not a non-empty 2D array — treating as schema drift (never a fabricated empty).",
337
+ );
338
+ }
339
+ const header = body[0];
340
+ if (
341
+ !Array.isArray(header) ||
342
+ header.length === 0 ||
343
+ !header.every((h) => typeof h === "string")
344
+ ) {
345
+ throw driftError(
346
+ CENSUS_DATA_LABEL,
347
+ "Census CBP row 0 is not a string[] header row — treating as schema drift (the 2D-array contract changed; never a fabricated empty).",
348
+ );
349
+ }
350
+
351
+ // Header-name → column index (order-independent; a missing column ⇒ index -1 ⇒
352
+ // the field maps to null, never a positional mis-read).
353
+ const idx = new Map<string, number>();
354
+ (header as string[]).forEach((h, i) => idx.set(h, i));
355
+ const col = (row: unknown[], name: string): unknown => {
356
+ const i = idx.get(name);
357
+ return i === undefined || i < 0 ? undefined : row[i];
358
+ };
359
+
360
+ const allRows: CbpRow[] = [];
361
+ for (let i = 1; i < body.length; i++) {
362
+ const raw = body[i];
363
+ if (!Array.isArray(raw)) {
364
+ throw driftError(
365
+ CENSUS_DATA_LABEL,
366
+ `Census CBP data row ${i} is not an array — treating as schema drift (never a fabricated empty).`,
367
+ );
368
+ }
369
+ const row = raw as unknown[];
370
+ allRows.push({
371
+ name: str(col(row, "NAME")),
372
+ geoId: str(col(row, "GEO_ID")),
373
+ naicsCode: str(col(row, "NAICS2017")),
374
+ naicsLabel: str(col(row, "NAICS2017_LABEL")),
375
+ establishments: censusNum(col(row, "ESTAB")),
376
+ employees: censusNum(col(row, "EMP")),
377
+ annualPayrollUsd: mul1000(censusNum(col(row, "PAYANN"))),
378
+ state: str(col(row, "state")),
379
+ });
380
+ }
381
+
382
+ // ── [P1] The COMPLETE set for the filter (no server pagination). An OPTIONAL
383
+ // client-side top-N slice is disclosed (totalAvailable stays the full count,
384
+ // so buildMeta derives truncated/complete honestly). ──
385
+ const totalAvailable = allRows.length;
386
+ const notes: string[] = [
387
+ KEY_REQUIRED_NOTE,
388
+ PAYROLL_UNITS_NOTE,
389
+ SUPPRESSED_NOTE,
390
+ NO_PAGINATION_NOTE,
391
+ ];
392
+
393
+ let rows = allRows;
394
+ if (
395
+ typeof args.limit === "number" &&
396
+ Number.isFinite(args.limit) &&
397
+ args.limit >= 0 &&
398
+ args.limit < allRows.length
399
+ ) {
400
+ rows = allRows.slice(0, args.limit);
401
+ notes.push(
402
+ `Returned the first ${rows.length} of ${totalAvailable} rows (client-side limit=${args.limit}); CBP has NO server-side pagination, so the remaining ${totalAvailable - rows.length} are not fetched separately — raise limit or narrow the filter to see them.`,
403
+ );
404
+ }
405
+
406
+ const filtersApplied = [
407
+ args.naics !== undefined ? `naics:${args.naics}` : "naics:(all)",
408
+ geoFilter,
409
+ `year:${year}`,
410
+ ];
411
+
412
+ const meta: Partial<ResponseMeta> = {
413
+ // MODE only — never the key value (K-test).
414
+ source: `api.census.gov /data/${year}/cbp (County Business Patterns; CENSUS_API_KEY)`,
415
+ keylessMode: false, // ★KEYED — the first key-required source
416
+ returned: rows.length,
417
+ totalAvailable,
418
+ filtersApplied,
419
+ filtersDropped: [],
420
+ fieldsUnavailable: [],
421
+ notes,
422
+ };
423
+
424
+ return withMeta({ rows }, meta);
425
+ }