@cliwant/mcp-sam-gov 1.5.0 → 1.6.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 (84) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +240 -231
  3. package/README.ko.md +240 -231
  4. package/README.md +725 -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 +44 -2
  14. package/dist/server.js.map +1 -1
  15. package/package.json +111 -111
  16. package/src/attachments.ts +652 -652
  17. package/src/bea.ts +372 -372
  18. package/src/bls.ts +1943 -1943
  19. package/src/cache.ts +73 -73
  20. package/src/cbp-border.ts +177 -177
  21. package/src/census-economic.ts +431 -431
  22. package/src/census.ts +735 -735
  23. package/src/ckan.ts +495 -495
  24. package/src/clinicaltrials.ts +923 -923
  25. package/src/cms-facility.ts +379 -379
  26. package/src/cms-hospital.ts +344 -344
  27. package/src/cms-supplier.ts +527 -527
  28. package/src/cms-utilization.ts +389 -389
  29. package/src/cms.ts +634 -634
  30. package/src/coerce.ts +47 -47
  31. package/src/courtlistener.ts +465 -465
  32. package/src/cpsc.ts +333 -333
  33. package/src/datagov-catalog.ts +312 -312
  34. package/src/datagov.ts +907 -907
  35. package/src/datagovKey.ts +68 -68
  36. package/src/datasource.ts +721 -721
  37. package/src/disclosure.ts +61 -61
  38. package/src/dol.ts +515 -515
  39. package/src/ecfr.ts +248 -248
  40. package/src/echo.ts +496 -496
  41. package/src/edgar.ts +3046 -3046
  42. package/src/epa-envirofacts.ts +358 -358
  43. package/src/errors.ts +324 -314
  44. package/src/fac.ts +529 -529
  45. package/src/far.ts +1009 -1009
  46. package/src/fdic.ts +2052 -2052
  47. package/src/federal-register.ts +725 -725
  48. package/src/feedback.ts +160 -0
  49. package/src/fema.ts +680 -680
  50. package/src/fpds.ts +620 -620
  51. package/src/fred.ts +464 -464
  52. package/src/gao.ts +744 -744
  53. package/src/gov-domains.ts +237 -237
  54. package/src/govinfo.ts +497 -497
  55. package/src/grants.ts +290 -290
  56. package/src/gsa-csv.ts +992 -992
  57. package/src/gsa-perdiem.ts +361 -361
  58. package/src/integrity.ts +928 -928
  59. package/src/keys.ts +268 -268
  60. package/src/lda.ts +385 -385
  61. package/src/meta.ts +292 -292
  62. package/src/nhtsa.ts +352 -352
  63. package/src/nih.ts +375 -375
  64. package/src/nist-controls.ts +219 -219
  65. package/src/nonprofit.ts +460 -460
  66. package/src/nppes.ts +834 -834
  67. package/src/nsf.ts +706 -706
  68. package/src/nvd.ts +1124 -1124
  69. package/src/nws-weather.ts +167 -167
  70. package/src/ofac.ts +1166 -1166
  71. package/src/openfda-device.ts +356 -356
  72. package/src/openfda-drugsfda.ts +313 -313
  73. package/src/openfda.ts +518 -518
  74. package/src/pricing.ts +1075 -1075
  75. package/src/sam-gov/client.ts +774 -774
  76. package/src/sam-gov/index.ts +32 -32
  77. package/src/sam-gov/types.ts +152 -152
  78. package/src/sba.ts +357 -357
  79. package/src/server.ts +6688 -6639
  80. package/src/snapshot.ts +223 -223
  81. package/src/socrata.ts +532 -532
  82. package/src/treasury.ts +582 -582
  83. package/src/usaspending.ts +2852 -2852
  84. 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
+ }