@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.
- package/LICENSE +21 -21
- package/README.ja.md +248 -231
- package/README.ko.md +248 -231
- package/README.md +733 -714
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/feedback.d.ts +64 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +131 -0
- package/dist/feedback.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +48 -2
- package/dist/server.js.map +1 -1
- package/dist/update-check.d.ts +38 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +85 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +111 -111
- package/src/attachments.ts +652 -652
- package/src/bea.ts +372 -372
- package/src/bls.ts +1943 -1943
- package/src/cache.ts +73 -73
- package/src/cbp-border.ts +177 -177
- package/src/census-economic.ts +431 -431
- package/src/census.ts +735 -735
- package/src/ckan.ts +495 -495
- package/src/clinicaltrials.ts +923 -923
- package/src/cms-facility.ts +379 -379
- package/src/cms-hospital.ts +344 -344
- package/src/cms-supplier.ts +527 -527
- package/src/cms-utilization.ts +389 -389
- package/src/cms.ts +634 -634
- package/src/coerce.ts +47 -47
- package/src/courtlistener.ts +465 -465
- package/src/cpsc.ts +333 -333
- package/src/datagov-catalog.ts +312 -312
- package/src/datagov.ts +907 -907
- package/src/datagovKey.ts +68 -68
- package/src/datasource.ts +721 -721
- package/src/disclosure.ts +61 -61
- package/src/dol.ts +515 -515
- package/src/ecfr.ts +248 -248
- package/src/echo.ts +496 -496
- package/src/edgar.ts +3046 -3046
- package/src/epa-envirofacts.ts +358 -358
- package/src/errors.ts +324 -314
- package/src/fac.ts +529 -529
- package/src/far.ts +1009 -1009
- package/src/fdic.ts +2052 -2052
- package/src/federal-register.ts +725 -725
- package/src/feedback.ts +160 -0
- package/src/fema.ts +680 -680
- package/src/fpds.ts +620 -620
- package/src/fred.ts +464 -464
- package/src/gao.ts +744 -744
- package/src/gov-domains.ts +237 -237
- package/src/govinfo.ts +497 -497
- package/src/grants.ts +290 -290
- package/src/gsa-csv.ts +992 -992
- package/src/gsa-perdiem.ts +361 -361
- package/src/integrity.ts +928 -928
- package/src/keys.ts +268 -268
- package/src/lda.ts +385 -385
- package/src/meta.ts +292 -292
- package/src/nhtsa.ts +352 -352
- package/src/nih.ts +375 -375
- package/src/nist-controls.ts +219 -219
- package/src/nonprofit.ts +460 -460
- package/src/nppes.ts +834 -834
- package/src/nsf.ts +706 -706
- package/src/nvd.ts +1124 -1124
- package/src/nws-weather.ts +167 -167
- package/src/ofac.ts +1166 -1166
- package/src/openfda-device.ts +356 -356
- package/src/openfda-drugsfda.ts +313 -313
- package/src/openfda.ts +518 -518
- package/src/pricing.ts +1075 -1075
- package/src/sam-gov/client.ts +774 -774
- package/src/sam-gov/index.ts +32 -32
- package/src/sam-gov/types.ts +152 -152
- package/src/sba.ts +357 -357
- package/src/server.ts +6692 -6639
- package/src/snapshot.ts +223 -223
- package/src/socrata.ts +532 -532
- package/src/treasury.ts +582 -582
- package/src/update-check.ts +88 -0
- package/src/usaspending.ts +2852 -2852
- 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
|
+
}
|