@cliwant/mcp-sam-gov 1.5.0 → 1.7.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 (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +248 -231
  3. package/README.ko.md +248 -231
  4. package/README.md +733 -714
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js.map +1 -1
  8. package/dist/feedback.d.ts +64 -0
  9. package/dist/feedback.d.ts.map +1 -0
  10. package/dist/feedback.js +131 -0
  11. package/dist/feedback.js.map +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +48 -2
  14. package/dist/server.js.map +1 -1
  15. package/dist/update-check.d.ts +38 -0
  16. package/dist/update-check.d.ts.map +1 -0
  17. package/dist/update-check.js +85 -0
  18. package/dist/update-check.js.map +1 -0
  19. package/package.json +111 -111
  20. package/src/attachments.ts +652 -652
  21. package/src/bea.ts +372 -372
  22. package/src/bls.ts +1943 -1943
  23. package/src/cache.ts +73 -73
  24. package/src/cbp-border.ts +177 -177
  25. package/src/census-economic.ts +431 -431
  26. package/src/census.ts +735 -735
  27. package/src/ckan.ts +495 -495
  28. package/src/clinicaltrials.ts +923 -923
  29. package/src/cms-facility.ts +379 -379
  30. package/src/cms-hospital.ts +344 -344
  31. package/src/cms-supplier.ts +527 -527
  32. package/src/cms-utilization.ts +389 -389
  33. package/src/cms.ts +634 -634
  34. package/src/coerce.ts +47 -47
  35. package/src/courtlistener.ts +465 -465
  36. package/src/cpsc.ts +333 -333
  37. package/src/datagov-catalog.ts +312 -312
  38. package/src/datagov.ts +907 -907
  39. package/src/datagovKey.ts +68 -68
  40. package/src/datasource.ts +721 -721
  41. package/src/disclosure.ts +61 -61
  42. package/src/dol.ts +515 -515
  43. package/src/ecfr.ts +248 -248
  44. package/src/echo.ts +496 -496
  45. package/src/edgar.ts +3046 -3046
  46. package/src/epa-envirofacts.ts +358 -358
  47. package/src/errors.ts +324 -314
  48. package/src/fac.ts +529 -529
  49. package/src/far.ts +1009 -1009
  50. package/src/fdic.ts +2052 -2052
  51. package/src/federal-register.ts +725 -725
  52. package/src/feedback.ts +160 -0
  53. package/src/fema.ts +680 -680
  54. package/src/fpds.ts +620 -620
  55. package/src/fred.ts +464 -464
  56. package/src/gao.ts +744 -744
  57. package/src/gov-domains.ts +237 -237
  58. package/src/govinfo.ts +497 -497
  59. package/src/grants.ts +290 -290
  60. package/src/gsa-csv.ts +992 -992
  61. package/src/gsa-perdiem.ts +361 -361
  62. package/src/integrity.ts +928 -928
  63. package/src/keys.ts +268 -268
  64. package/src/lda.ts +385 -385
  65. package/src/meta.ts +292 -292
  66. package/src/nhtsa.ts +352 -352
  67. package/src/nih.ts +375 -375
  68. package/src/nist-controls.ts +219 -219
  69. package/src/nonprofit.ts +460 -460
  70. package/src/nppes.ts +834 -834
  71. package/src/nsf.ts +706 -706
  72. package/src/nvd.ts +1124 -1124
  73. package/src/nws-weather.ts +167 -167
  74. package/src/ofac.ts +1166 -1166
  75. package/src/openfda-device.ts +356 -356
  76. package/src/openfda-drugsfda.ts +313 -313
  77. package/src/openfda.ts +518 -518
  78. package/src/pricing.ts +1075 -1075
  79. package/src/sam-gov/client.ts +774 -774
  80. package/src/sam-gov/index.ts +32 -32
  81. package/src/sam-gov/types.ts +152 -152
  82. package/src/sba.ts +357 -357
  83. package/src/server.ts +6692 -6639
  84. package/src/snapshot.ts +223 -223
  85. package/src/socrata.ts +532 -532
  86. package/src/treasury.ts +582 -582
  87. package/src/update-check.ts +88 -0
  88. package/src/usaspending.ts +2852 -2852
  89. package/src/usitc.ts +420 -420
package/src/cache.ts CHANGED
@@ -1,73 +1,73 @@
1
- /**
2
- * Tiny in-memory TTL cache for hot, idempotent reads.
3
- *
4
- * Why this exists
5
- * ----------------
6
- * Some calls are extremely repeat-prone within a single agent
7
- * conversation: `usas_lookup_agency("VA")`, `ecfr_list_titles()`,
8
- * `fed_register_list_agencies()`, `usas_autocomplete_naics(...)`.
9
- * The agent will call them five times in a row across different
10
- * tool sequences. Each is a 250-700ms federal API hit.
11
- *
12
- * This cache is per-process (no Redis, no disk). Lives for the
13
- * lifetime of the MCP server stdio session — typically minutes
14
- * to hours. TTL is short enough that schema drift gets noticed
15
- * within an hour.
16
- *
17
- * What we cache
18
- * --------------
19
- * - Reference lookups (agencies, NAICS hierarchy, glossary)
20
- * - Autocomplete (idempotent for same query string)
21
- * What we DON'T cache
22
- * --------------------
23
- * - Search results (volume changes; user expects freshness)
24
- * - Per-opportunity / per-award detail (stale = wrong)
25
- * - Anything with a date filter
26
- */
27
-
28
- type Entry<T> = { value: T; expiresAt: number };
29
-
30
- const store = new Map<string, Entry<unknown>>();
31
-
32
- const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
33
-
34
- /**
35
- * Wrap an idempotent async producer in a TTL cache.
36
- *
37
- * const result = await memoize("usas:agency:VA", () => lookupAgency("VA"));
38
- *
39
- * Returns the cached value if fresh; otherwise computes + stores.
40
- */
41
- export async function memoize<T>(
42
- key: string,
43
- producer: () => Promise<T>,
44
- ttlMs: number = DEFAULT_TTL_MS,
45
- ): Promise<T> {
46
- const now = Date.now();
47
- const hit = store.get(key);
48
- if (hit && hit.expiresAt > now) {
49
- return hit.value as T;
50
- }
51
- const value = await producer();
52
- store.set(key, { value, expiresAt: now + ttlMs });
53
- // Light-touch GC: every 100 sets, sweep expired entries.
54
- if (store.size % 100 === 0) sweepExpired();
55
- return value;
56
- }
57
-
58
- function sweepExpired() {
59
- const now = Date.now();
60
- for (const [k, v] of store) {
61
- if (v.expiresAt <= now) store.delete(k);
62
- }
63
- }
64
-
65
- /** For tests / debug. */
66
- export function _cacheStats() {
67
- return { size: store.size, entries: [...store.keys()] };
68
- }
69
-
70
- /** For tests: evict all entries (so a subsequent call re-runs its producer). */
71
- export function _clearCache() {
72
- store.clear();
73
- }
1
+ /**
2
+ * Tiny in-memory TTL cache for hot, idempotent reads.
3
+ *
4
+ * Why this exists
5
+ * ----------------
6
+ * Some calls are extremely repeat-prone within a single agent
7
+ * conversation: `usas_lookup_agency("VA")`, `ecfr_list_titles()`,
8
+ * `fed_register_list_agencies()`, `usas_autocomplete_naics(...)`.
9
+ * The agent will call them five times in a row across different
10
+ * tool sequences. Each is a 250-700ms federal API hit.
11
+ *
12
+ * This cache is per-process (no Redis, no disk). Lives for the
13
+ * lifetime of the MCP server stdio session — typically minutes
14
+ * to hours. TTL is short enough that schema drift gets noticed
15
+ * within an hour.
16
+ *
17
+ * What we cache
18
+ * --------------
19
+ * - Reference lookups (agencies, NAICS hierarchy, glossary)
20
+ * - Autocomplete (idempotent for same query string)
21
+ * What we DON'T cache
22
+ * --------------------
23
+ * - Search results (volume changes; user expects freshness)
24
+ * - Per-opportunity / per-award detail (stale = wrong)
25
+ * - Anything with a date filter
26
+ */
27
+
28
+ type Entry<T> = { value: T; expiresAt: number };
29
+
30
+ const store = new Map<string, Entry<unknown>>();
31
+
32
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
33
+
34
+ /**
35
+ * Wrap an idempotent async producer in a TTL cache.
36
+ *
37
+ * const result = await memoize("usas:agency:VA", () => lookupAgency("VA"));
38
+ *
39
+ * Returns the cached value if fresh; otherwise computes + stores.
40
+ */
41
+ export async function memoize<T>(
42
+ key: string,
43
+ producer: () => Promise<T>,
44
+ ttlMs: number = DEFAULT_TTL_MS,
45
+ ): Promise<T> {
46
+ const now = Date.now();
47
+ const hit = store.get(key);
48
+ if (hit && hit.expiresAt > now) {
49
+ return hit.value as T;
50
+ }
51
+ const value = await producer();
52
+ store.set(key, { value, expiresAt: now + ttlMs });
53
+ // Light-touch GC: every 100 sets, sweep expired entries.
54
+ if (store.size % 100 === 0) sweepExpired();
55
+ return value;
56
+ }
57
+
58
+ function sweepExpired() {
59
+ const now = Date.now();
60
+ for (const [k, v] of store) {
61
+ if (v.expiresAt <= now) store.delete(k);
62
+ }
63
+ }
64
+
65
+ /** For tests / debug. */
66
+ export function _cacheStats() {
67
+ return { size: store.size, entries: [...store.keys()] };
68
+ }
69
+
70
+ /** For tests: evict all entries (so a subsequent call re-runs its producer). */
71
+ export function _clearCache() {
72
+ store.clear();
73
+ }
package/src/cbp-border.ts CHANGED
@@ -1,177 +1,177 @@
1
- /**
2
- * cbp-border.ts — CBP Border Wait Times (bwt.cbp.gov, KEYLESS) — the FREIGHT /
3
- * LOGISTICS lane. Live commercial-vehicle (and passenger) wait times at every US
4
- * land border port (Canadian + Mexican): per-port lane delays, operational status,
5
- * and open-lane counts. Answers "what's the current commercial-truck delay at port
6
- * X" — real-time freight-crossing situational awareness for logistics/trade vendors.
7
- *
8
- * SOURCE: CBP's official Border Wait Times API (bwt.cbp.gov/api/bwtnew) — a .gov host,
9
- * keyless, returns a JSON ARRAY of ports. This is REAL-TIME operational data: each
10
- * lane carries its own `update_time` (surfaced verbatim) — freshness is disclosed and
11
- * never implied to be live-to-the-second.
12
- *
13
- * HONESTY: fixed host + redirect:"error" (SSRF); a non-array body ⇒ driftError (never
14
- * a fake empty); an outage/4xx/timeout THROWS. delay/lanes are upstream STRINGS →
15
- * number|null (a real 0 stays 0; an empty/N/A value is null, NEVER a fabricated 0 — a
16
- * closed lane's delay is UNKNOWN, not "0 minutes"). totalAvailable = the EXACT count
17
- * of matched ports (client-side filter; the API returns the whole set).
18
- */
19
-
20
- import { getJson, driftError } from "./datasource.js";
21
- import { memoize } from "./cache.js";
22
- import { num } from "./coerce.js";
23
- import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
24
-
25
- export const CBP_HOST = "bwt.cbp.gov";
26
- const CBP_URL = "https://bwt.cbp.gov/api/bwtnew";
27
- const CBP_LABEL = "cbp:border-wait-times";
28
- const CBP_TIMEOUT_MS = 15_000;
29
- // Real-time feed — a SHORT 60s cache (upstream politeness) while staying fresh; the
30
- // per-lane update_time is the authoritative freshness signal, disclosed per port.
31
- const CBP_CACHE_TTL_MS = 60_000;
32
-
33
- const FRESHNESS_NOTE =
34
- "REAL-TIME operational data: each lane carries its own asOf/updateTime (surfaced verbatim) — this is a live border-wait snapshot, not a historical series. A closed port or lane reports operationalStatus accordingly; its delayMinutes is null (UNKNOWN), never a fabricated 0.";
35
- const PROVENANCE_NOTE =
36
- "Source: CBP Border Wait Times API (bwt.cbp.gov), keyless. Covers all US land border ports on the Canadian and Mexican borders.";
37
-
38
- type RawLane = {
39
- update_time?: string;
40
- operational_status?: string;
41
- delay_minutes?: string;
42
- lanes_open?: string;
43
- };
44
- type RawPort = {
45
- port_number?: string;
46
- border?: string;
47
- port_name?: string;
48
- crossing_name?: string;
49
- date?: string;
50
- time?: string;
51
- port_status?: string;
52
- commercial_vehicle_lanes?: { maximum_lanes?: unknown } & Record<string, unknown>;
53
- passenger_vehicle_lanes?: Record<string, unknown>;
54
- };
55
-
56
- export type CbpLane = {
57
- operationalStatus: string | null;
58
- delayMinutes: number | null; // null-never-0: a real 0 stays 0; empty/N/A ⇒ null
59
- lanesOpen: number | null;
60
- updateTime: string | null;
61
- };
62
- export type CbpPort = {
63
- portNumber: string | null;
64
- portName: string | null;
65
- crossingName: string | null;
66
- border: string | null; // "Canadian Border" | "Mexican Border"
67
- portStatus: string | null; // "Open" | "Closed"
68
- asOf: string | null; // date + time from the feed
69
- commercialVehicle: { maxLanes: number | null; standard: CbpLane; fast: CbpLane };
70
- };
71
-
72
- /** Trim to a non-empty string or null (never ""). */
73
- function s(v: unknown): string | null {
74
- if (typeof v !== "string") return v == null ? null : String(v);
75
- const t = v.trim();
76
- return t.length > 0 ? t : null;
77
- }
78
-
79
- /** Map ONE lane object → curated lane (delay/lanes via `num`: 0 stays 0, ""→null). */
80
- function mapLane(lane: unknown): CbpLane {
81
- const l = (lane ?? {}) as RawLane;
82
- return {
83
- operationalStatus: s(l.operational_status),
84
- delayMinutes: num(l.delay_minutes),
85
- lanesOpen: num(l.lanes_open),
86
- updateTime: s(l.update_time),
87
- };
88
- }
89
-
90
- function mapPort(port: RawPort): CbpPort {
91
- const cv = (port.commercial_vehicle_lanes ?? {}) as Record<string, unknown>;
92
- const date = s(port.date);
93
- const time = s(port.time);
94
- return {
95
- portNumber: s(port.port_number),
96
- portName: s(port.port_name),
97
- crossingName: s(port.crossing_name),
98
- border: s(port.border),
99
- portStatus: s(port.port_status),
100
- asOf: date && time ? `${date} ${time}` : (date ?? time),
101
- commercialVehicle: {
102
- maxLanes: num(cv.maximum_lanes),
103
- standard: mapLane(cv.standard_lanes),
104
- fast: mapLane(cv.FAST_lanes),
105
- },
106
- };
107
- }
108
-
109
- /** Fetch + parse the full port array, memoized 60s. A non-array body ⇒ driftError. */
110
- async function loadPorts(): Promise<CbpPort[]> {
111
- return memoize(
112
- "cbp:bwt",
113
- async () => {
114
- const built = new URL(CBP_URL);
115
- if (built.hostname !== CBP_HOST || built.protocol !== "https:") {
116
- throw driftError(CBP_LABEL, `Constructed CBP URL host ${JSON.stringify(built.hostname)} is not ${CBP_HOST} over https — refusing to fetch (SSRF safety).`);
117
- }
118
- const body = await getJson(CBP_URL, { label: CBP_LABEL, redirect: "error", timeoutMs: CBP_TIMEOUT_MS });
119
- if (!Array.isArray(body)) {
120
- throw driftError(CBP_LABEL, "CBP Border Wait Times returned a non-array body — schema drift, never a fake-empty result.");
121
- }
122
- return (body as RawPort[]).map(mapPort);
123
- },
124
- CBP_CACHE_TTL_MS,
125
- );
126
- }
127
-
128
- // ─── Tool: cbp_border_wait_times ──────────────────────────────────
129
- /**
130
- * List CBP land-border-port commercial-vehicle (+ passenger) wait times, optionally
131
- * filtered by border (Canadian/Mexican) and/or port name (substring). Client-side
132
- * filter over the live feed; honest `_meta` (exact match total + real-time freshness).
133
- */
134
- export async function borderWaitTimes(args: {
135
- border?: string;
136
- portName?: string;
137
- limit?: number;
138
- offset?: number;
139
- }): Promise<MetaBundle> {
140
- const limit = args.limit ?? 100;
141
- const offset = args.offset ?? 0;
142
- const all = await loadPorts();
143
-
144
- const filtersApplied: string[] = [];
145
- const borderQ = args.border?.trim().toLowerCase();
146
- const portQ = args.portName?.trim().toLowerCase();
147
- if (args.border !== undefined) filtersApplied.push("border");
148
- if (args.portName !== undefined) filtersApplied.push("portName");
149
-
150
- const matched = all.filter((p) => {
151
- if (borderQ && !(p.border ?? "").toLowerCase().includes(borderQ)) return false;
152
- if (portQ && !(p.portName ?? "").toLowerCase().includes(portQ)) return false;
153
- return true;
154
- });
155
-
156
- const totalAvailable = matched.length;
157
- const page = matched.slice(offset, offset + limit);
158
- const returned = page.length;
159
- const hasMore = offset + returned < totalAvailable;
160
- const nextOffset = hasMore ? offset + returned : null;
161
-
162
- return withMeta(
163
- { ports: page },
164
- {
165
- source: "bwt.cbp.gov Border Wait Times (keyless)",
166
- keylessMode: true,
167
- returned,
168
- totalAvailable,
169
- truncated: hasMore,
170
- filtersApplied,
171
- filtersDropped: [],
172
- fieldsUnavailable: [],
173
- pagination: { offset, limit, hasMore, nextOffset },
174
- notes: [PROVENANCE_NOTE, FRESHNESS_NOTE],
175
- } satisfies Partial<ResponseMeta>,
176
- );
177
- }
1
+ /**
2
+ * cbp-border.ts — CBP Border Wait Times (bwt.cbp.gov, KEYLESS) — the FREIGHT /
3
+ * LOGISTICS lane. Live commercial-vehicle (and passenger) wait times at every US
4
+ * land border port (Canadian + Mexican): per-port lane delays, operational status,
5
+ * and open-lane counts. Answers "what's the current commercial-truck delay at port
6
+ * X" — real-time freight-crossing situational awareness for logistics/trade vendors.
7
+ *
8
+ * SOURCE: CBP's official Border Wait Times API (bwt.cbp.gov/api/bwtnew) — a .gov host,
9
+ * keyless, returns a JSON ARRAY of ports. This is REAL-TIME operational data: each
10
+ * lane carries its own `update_time` (surfaced verbatim) — freshness is disclosed and
11
+ * never implied to be live-to-the-second.
12
+ *
13
+ * HONESTY: fixed host + redirect:"error" (SSRF); a non-array body ⇒ driftError (never
14
+ * a fake empty); an outage/4xx/timeout THROWS. delay/lanes are upstream STRINGS →
15
+ * number|null (a real 0 stays 0; an empty/N/A value is null, NEVER a fabricated 0 — a
16
+ * closed lane's delay is UNKNOWN, not "0 minutes"). totalAvailable = the EXACT count
17
+ * of matched ports (client-side filter; the API returns the whole set).
18
+ */
19
+
20
+ import { getJson, driftError } from "./datasource.js";
21
+ import { memoize } from "./cache.js";
22
+ import { num } from "./coerce.js";
23
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
24
+
25
+ export const CBP_HOST = "bwt.cbp.gov";
26
+ const CBP_URL = "https://bwt.cbp.gov/api/bwtnew";
27
+ const CBP_LABEL = "cbp:border-wait-times";
28
+ const CBP_TIMEOUT_MS = 15_000;
29
+ // Real-time feed — a SHORT 60s cache (upstream politeness) while staying fresh; the
30
+ // per-lane update_time is the authoritative freshness signal, disclosed per port.
31
+ const CBP_CACHE_TTL_MS = 60_000;
32
+
33
+ const FRESHNESS_NOTE =
34
+ "REAL-TIME operational data: each lane carries its own asOf/updateTime (surfaced verbatim) — this is a live border-wait snapshot, not a historical series. A closed port or lane reports operationalStatus accordingly; its delayMinutes is null (UNKNOWN), never a fabricated 0.";
35
+ const PROVENANCE_NOTE =
36
+ "Source: CBP Border Wait Times API (bwt.cbp.gov), keyless. Covers all US land border ports on the Canadian and Mexican borders.";
37
+
38
+ type RawLane = {
39
+ update_time?: string;
40
+ operational_status?: string;
41
+ delay_minutes?: string;
42
+ lanes_open?: string;
43
+ };
44
+ type RawPort = {
45
+ port_number?: string;
46
+ border?: string;
47
+ port_name?: string;
48
+ crossing_name?: string;
49
+ date?: string;
50
+ time?: string;
51
+ port_status?: string;
52
+ commercial_vehicle_lanes?: { maximum_lanes?: unknown } & Record<string, unknown>;
53
+ passenger_vehicle_lanes?: Record<string, unknown>;
54
+ };
55
+
56
+ export type CbpLane = {
57
+ operationalStatus: string | null;
58
+ delayMinutes: number | null; // null-never-0: a real 0 stays 0; empty/N/A ⇒ null
59
+ lanesOpen: number | null;
60
+ updateTime: string | null;
61
+ };
62
+ export type CbpPort = {
63
+ portNumber: string | null;
64
+ portName: string | null;
65
+ crossingName: string | null;
66
+ border: string | null; // "Canadian Border" | "Mexican Border"
67
+ portStatus: string | null; // "Open" | "Closed"
68
+ asOf: string | null; // date + time from the feed
69
+ commercialVehicle: { maxLanes: number | null; standard: CbpLane; fast: CbpLane };
70
+ };
71
+
72
+ /** Trim to a non-empty string or null (never ""). */
73
+ function s(v: unknown): string | null {
74
+ if (typeof v !== "string") return v == null ? null : String(v);
75
+ const t = v.trim();
76
+ return t.length > 0 ? t : null;
77
+ }
78
+
79
+ /** Map ONE lane object → curated lane (delay/lanes via `num`: 0 stays 0, ""→null). */
80
+ function mapLane(lane: unknown): CbpLane {
81
+ const l = (lane ?? {}) as RawLane;
82
+ return {
83
+ operationalStatus: s(l.operational_status),
84
+ delayMinutes: num(l.delay_minutes),
85
+ lanesOpen: num(l.lanes_open),
86
+ updateTime: s(l.update_time),
87
+ };
88
+ }
89
+
90
+ function mapPort(port: RawPort): CbpPort {
91
+ const cv = (port.commercial_vehicle_lanes ?? {}) as Record<string, unknown>;
92
+ const date = s(port.date);
93
+ const time = s(port.time);
94
+ return {
95
+ portNumber: s(port.port_number),
96
+ portName: s(port.port_name),
97
+ crossingName: s(port.crossing_name),
98
+ border: s(port.border),
99
+ portStatus: s(port.port_status),
100
+ asOf: date && time ? `${date} ${time}` : (date ?? time),
101
+ commercialVehicle: {
102
+ maxLanes: num(cv.maximum_lanes),
103
+ standard: mapLane(cv.standard_lanes),
104
+ fast: mapLane(cv.FAST_lanes),
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Fetch + parse the full port array, memoized 60s. A non-array body ⇒ driftError. */
110
+ async function loadPorts(): Promise<CbpPort[]> {
111
+ return memoize(
112
+ "cbp:bwt",
113
+ async () => {
114
+ const built = new URL(CBP_URL);
115
+ if (built.hostname !== CBP_HOST || built.protocol !== "https:") {
116
+ throw driftError(CBP_LABEL, `Constructed CBP URL host ${JSON.stringify(built.hostname)} is not ${CBP_HOST} over https — refusing to fetch (SSRF safety).`);
117
+ }
118
+ const body = await getJson(CBP_URL, { label: CBP_LABEL, redirect: "error", timeoutMs: CBP_TIMEOUT_MS });
119
+ if (!Array.isArray(body)) {
120
+ throw driftError(CBP_LABEL, "CBP Border Wait Times returned a non-array body — schema drift, never a fake-empty result.");
121
+ }
122
+ return (body as RawPort[]).map(mapPort);
123
+ },
124
+ CBP_CACHE_TTL_MS,
125
+ );
126
+ }
127
+
128
+ // ─── Tool: cbp_border_wait_times ──────────────────────────────────
129
+ /**
130
+ * List CBP land-border-port commercial-vehicle (+ passenger) wait times, optionally
131
+ * filtered by border (Canadian/Mexican) and/or port name (substring). Client-side
132
+ * filter over the live feed; honest `_meta` (exact match total + real-time freshness).
133
+ */
134
+ export async function borderWaitTimes(args: {
135
+ border?: string;
136
+ portName?: string;
137
+ limit?: number;
138
+ offset?: number;
139
+ }): Promise<MetaBundle> {
140
+ const limit = args.limit ?? 100;
141
+ const offset = args.offset ?? 0;
142
+ const all = await loadPorts();
143
+
144
+ const filtersApplied: string[] = [];
145
+ const borderQ = args.border?.trim().toLowerCase();
146
+ const portQ = args.portName?.trim().toLowerCase();
147
+ if (args.border !== undefined) filtersApplied.push("border");
148
+ if (args.portName !== undefined) filtersApplied.push("portName");
149
+
150
+ const matched = all.filter((p) => {
151
+ if (borderQ && !(p.border ?? "").toLowerCase().includes(borderQ)) return false;
152
+ if (portQ && !(p.portName ?? "").toLowerCase().includes(portQ)) return false;
153
+ return true;
154
+ });
155
+
156
+ const totalAvailable = matched.length;
157
+ const page = matched.slice(offset, offset + limit);
158
+ const returned = page.length;
159
+ const hasMore = offset + returned < totalAvailable;
160
+ const nextOffset = hasMore ? offset + returned : null;
161
+
162
+ return withMeta(
163
+ { ports: page },
164
+ {
165
+ source: "bwt.cbp.gov Border Wait Times (keyless)",
166
+ keylessMode: true,
167
+ returned,
168
+ totalAvailable,
169
+ truncated: hasMore,
170
+ filtersApplied,
171
+ filtersDropped: [],
172
+ fieldsUnavailable: [],
173
+ pagination: { offset, limit, hasMore, nextOffset },
174
+ notes: [PROVENANCE_NOTE, FRESHNESS_NOTE],
175
+ } satisfies Partial<ResponseMeta>,
176
+ );
177
+ }