@cliwant/mcp-sam-gov 0.3.0 → 1.1.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 +49 -16
- package/README.ko.md +49 -16
- package/README.md +255 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census-economic.d.ts +93 -0
- package/dist/census-economic.d.ts.map +1 -0
- package/dist/census-economic.js +355 -0
- package/dist/census-economic.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/fred.d.ts +108 -0
- package/dist/fred.d.ts.map +1 -0
- package/dist/fred.js +373 -0
- package/dist/fred.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/keys.d.ts +83 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +173 -0
- package/dist/keys.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4032 -297
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +115 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +175 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census-economic.ts +425 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/fred.ts +464 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/keys.ts +216 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4717 -328
- package/src/snapshot.ts +223 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/src/socrata.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socrata / SODA — keyless open data for state / local (SLED) + E-rate portals.
|
|
3
|
+
*
|
|
4
|
+
* First SLED source (ADR-0004); 3rd consumer of the fetch/map/meta shape after
|
|
5
|
+
* treasury.ts / edgar.ts. Fully PUBLIC, KEYLESS. ONE connector reaches ~a dozen
|
|
6
|
+
* US state portals + E-rate on the IDENTICAL SODA JSON API — swap hostname + a
|
|
7
|
+
* 4x4 dataset id. Hosts are a CURATED allowlist (see SSRF below); the caller
|
|
8
|
+
* never supplies a free host or a free path.
|
|
9
|
+
* Row query: https://{domain}/resource/{4x4}.json?$select=…&$where=…&$limit=…
|
|
10
|
+
* Catalog: https://api.us.socrata.com/api/catalog/v1?domains={domain}&q=…
|
|
11
|
+
*
|
|
12
|
+
* Three layers (mirror treasury.ts / edgar.ts):
|
|
13
|
+
* fetch — `getSocrataResource` / `getCatalog`: SSRF-guard, build the URL,
|
|
14
|
+
* set `redirect:"error"` + the optional app-token header, reuse
|
|
15
|
+
* errors.ts retry/timeout/taxonomy. Return the parsed JSON.
|
|
16
|
+
* map — rows pass through mostly verbatim (already JSON; value fields are
|
|
17
|
+
* strings). `num(x)` → number|null (NEVER 0 for absent) for the count
|
|
18
|
+
* coercion. `mapCatalogRow` for discovery.
|
|
19
|
+
* meta — `withMeta(...)`: hands totalAvailable/returned/pagination/notes to
|
|
20
|
+
* meta.ts's `buildMeta`, which DERIVES complete/truncated.
|
|
21
|
+
*
|
|
22
|
+
* ★ SSRF GUARD (policy① — the central design risk). The `domain` param is an
|
|
23
|
+
* arbitrary-host vector, so it is a curated allowlist-enum + a fixed path (this
|
|
24
|
+
* mirrors the treasury `dataset` enum + SamAttachmentUrl fixed-base). Validation
|
|
25
|
+
* order: (1) domain ∈ SOCRATA_DOMAINS (a Zod enum at the server boundary, AND a
|
|
26
|
+
* belt-and-suspenders Set check here) → else invalid_input BEFORE any fetch;
|
|
27
|
+
* (2) datasetId matches /^[a-z0-9]{4}-[a-z0-9]{4}$/ AND is exactly 9 chars (M2 —
|
|
28
|
+
* `.length(9)` on the RAW string rejects a trailing `\n` that the regex `$` would
|
|
29
|
+
* otherwise admit; the Zod schema deliberately does NOT `.trim()`, since Zod
|
|
30
|
+
* trims BEFORE `.length(9)` and would strip the `\n` to a valid id — defeating
|
|
31
|
+
* the guard; see server.ts SocrataQueryInput);
|
|
32
|
+
* (3) construct the URL, then ASSERT `new URL(built).hostname === domain` and
|
|
33
|
+
* `protocol === "https:"`; (4) SoQL params go through URLSearchParams — encoded
|
|
34
|
+
* values, no host-alteration surface (bad SoQL is upstream-validated: 400 →
|
|
35
|
+
* invalid_input, surfaced, never silent). Adding a domain later = an allowlist
|
|
36
|
+
* SOURCE edit + a live `$limit=1` verification + a test note — NEVER a free
|
|
37
|
+
* runtime param.
|
|
38
|
+
*
|
|
39
|
+
* B1 (SSRF, redirect) — the pre-fetch `hostname === domain` assertion runs
|
|
40
|
+
* BEFORE fetch, which defaults to `redirect:"follow"`; a 3xx from an allowlisted
|
|
41
|
+
* host (migration / DNS-hijack / decommissioned-then-reused domain) would be
|
|
42
|
+
* followed to an off-allowlist target the pre-check never sees. `attachments.ts`
|
|
43
|
+
* closes this with a post-redirect `finalHost` check (~L484–515). SODA
|
|
44
|
+
* `/resource/{4x4}.json` and the catalog are direct-JSON endpoints, so a 3xx is
|
|
45
|
+
* anomalous → we set `redirect:"error"` in the init passed to fetchWithRetry for
|
|
46
|
+
* EVERY socrata + catalog fetch (row, count companion, catalog). errors.ts's
|
|
47
|
+
* fetchWithRetry calls `fetch(url, init)` directly (L139), forwarding `init`
|
|
48
|
+
* verbatim, so `redirect:"error"` IS honored (confirmed) — a redirect makes
|
|
49
|
+
* fetch throw, is never followed, and its body is never read back.
|
|
50
|
+
*
|
|
51
|
+
* B2 (honesty, hasMore) — SODA's row response has NO total, so `hasMore` must
|
|
52
|
+
* NOT short-circuit to false on an unknown total (that would lie complete:true
|
|
53
|
+
* on a full page and stop an agent mid-dataset). Formula:
|
|
54
|
+
* hasMore = totalAvailable !== null ? (offset + returned < totalAvailable)
|
|
55
|
+
* : (returned >= limit)
|
|
56
|
+
* SODA returns fewer than $limit ONLY when the result set is exhausted, so
|
|
57
|
+
* `returned >= limit` on an unknown total is the correct hedge (→ truncated).
|
|
58
|
+
*
|
|
59
|
+
* m3 (honesty, catalog drift) — `socrata_discover_datasets` is the catalog's
|
|
60
|
+
* PRIMARY response; if `typeof resultSetSize !== "number"` → hard schema_drift
|
|
61
|
+
* throw (nothing valid to return), then `totalAvailable = num(resultSetSize)`.
|
|
62
|
+
*
|
|
63
|
+
* m4 (honesty, count companion) — the count(*) companion is a best-effort
|
|
64
|
+
* enrichment of an ALREADY-SUCCESSFUL row query: ANY count failure → degrade to
|
|
65
|
+
* totalAvailable:null and STILL return the rows (never lose good data, never
|
|
66
|
+
* fake complete). The note distinguishes a transient fetch throw from a 200 +
|
|
67
|
+
* wrong shape (possible upstream API change). A hard schema_drift throw is
|
|
68
|
+
* reserved for a PRIMARY query (rows / catalog), NEVER the secondary count.
|
|
69
|
+
*
|
|
70
|
+
* ALLOWLIST — LIVE-VERIFIED 2026-07-10 (each carries a real sample 4x4). All
|
|
71
|
+
* `.gov` except `opendata.usac.org`:
|
|
72
|
+
* m6 — `opendata.usac.org` is a `.org` (USAC, a Congress-designated non-profit;
|
|
73
|
+
* E-rate). It is on the periodic re-verification checklist. NOTE: the
|
|
74
|
+
* federated discovery catalog (api.us.socrata.com) does NOT index USAC
|
|
75
|
+
* (returns resultSetSize 0), so `socrata_discover_datasets` will not
|
|
76
|
+
* surface it — but `socrata_query` works against it with a known 4x4
|
|
77
|
+
* (live: opendata.usac.org/resource/avi8-svp9.json → 200 bare array).
|
|
78
|
+
* M1 — MA is DROPPED from slice 1: `cthru.data.socrata.com` is a commercial
|
|
79
|
+
* vendor host (Tyler Technologies `.socrata.com`, not gov-controlled) and
|
|
80
|
+
* no `.gov` MA Socrata host verifies (`data.mass.gov` → the catalog
|
|
81
|
+
* answers "Domain not found"). Keep the allowlist all-`.gov` + the one
|
|
82
|
+
* `.org` USAC; revisit MA when a `data.mass.gov` (or documented
|
|
83
|
+
* trust-boundary caveat) verifies.
|
|
84
|
+
* CHURN-EXCLUDED — VA (→CKAN), IA (left Socrata → SODA 404s), MI (→SIGMA), and
|
|
85
|
+
* IL (`data.illinois.gov` — live 2026-07-10 the catalog returns 0
|
|
86
|
+
* datasets; its ids are "story" types whose resource endpoints 403/404,
|
|
87
|
+
* i.e. no queryable SODA table) are NOT in the allowlist.
|
|
88
|
+
*
|
|
89
|
+
* TOOL LEANNESS — slice 1 ships 2 core tools (socrata_query + socrata_discover_
|
|
90
|
+
* datasets). The optional `socrata_state_datasets` (a static state→portal→4x4
|
|
91
|
+
* map) is NOT shipped: a hand-curated list of specific 4x4 ids is precisely the
|
|
92
|
+
* staleness/honesty liability the churn exclusion warns about (a moved dataset
|
|
93
|
+
* silently 404s), and discovery is already served — always FRESH — by the live
|
|
94
|
+
* catalog in socrata_discover_datasets, while the state→domain mapping is already
|
|
95
|
+
* visible in the `domain` enum. So it does not earn its schema cost. (Documented
|
|
96
|
+
* for the reviewer.)
|
|
97
|
+
*
|
|
98
|
+
* Keyless-first — keyless works everywhere above (all live-verified without a
|
|
99
|
+
* token); a FREE `X-App-Token` only lifts the shared-IP 429 limits. Optional
|
|
100
|
+
* `SOCRATA_APP_TOKEN` → the `X-App-Token` header only; never required, never
|
|
101
|
+
* logged, never placed in `_meta` or an error (see m7 at the fetch call site).
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
105
|
+
import { getJson, driftError } from "./datasource.js";
|
|
106
|
+
import { num, str } from "./coerce.js";
|
|
107
|
+
import { memoize } from "./cache.js";
|
|
108
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
109
|
+
|
|
110
|
+
// Re-export the shared honesty coercion (single audited copy now lives in
|
|
111
|
+
// ./coerce.js — ADR-0005 v2 FIX-C) so existing importers and the fault suite's
|
|
112
|
+
// num-parity guard keep resolving `num` from this module.
|
|
113
|
+
export { num };
|
|
114
|
+
|
|
115
|
+
// ─── Curated allowlist (SSRF core) ────────────────────────────────
|
|
116
|
+
// A frozen list of live-verified (2026-07-10) Socrata SODA hosts. The Zod
|
|
117
|
+
// `domain` enum in server.ts is built FROM this array (single source of truth),
|
|
118
|
+
// and getSocrataResource re-checks membership (belt-and-suspenders). Each entry
|
|
119
|
+
// carries a real sample 4x4 confirmed to return a 200 bare array.
|
|
120
|
+
export const SOCRATA_DOMAINS = [
|
|
121
|
+
"data.ny.gov", // NY — e.g. kwxv-fwze
|
|
122
|
+
"data.colorado.gov", // CO — e.g. 4ykn-tg5h
|
|
123
|
+
"data.ct.gov", // CT — e.g. 28fr-iqnx
|
|
124
|
+
"data.texas.gov", // TX — e.g. 54pj-3dxy
|
|
125
|
+
"data.wa.gov", // WA — e.g. qxh8-f4bd
|
|
126
|
+
"opendata.maryland.gov", // MD — e.g. 2ir4-626w
|
|
127
|
+
"data.vermont.gov", // VT — e.g. jgqy-2smf
|
|
128
|
+
"data.nj.gov", // NJ — e.g. 44xg-bswk
|
|
129
|
+
"data.oregon.gov", // OR — e.g. tckn-sxa6
|
|
130
|
+
"data.pa.gov", // PA — e.g. mcba-yywm
|
|
131
|
+
"data.mo.gov", // MO — e.g. gfq7-aa86
|
|
132
|
+
"data.delaware.gov", // DE — e.g. 5zy2-grhr
|
|
133
|
+
"opendata.usac.org", // USAC E-rate (.org, m6) — e.g. avi8-svp9
|
|
134
|
+
] as const;
|
|
135
|
+
|
|
136
|
+
export type SocrataDomain = (typeof SOCRATA_DOMAINS)[number];
|
|
137
|
+
|
|
138
|
+
const SOCRATA_DOMAIN_SET: ReadonlySet<string> = new Set(SOCRATA_DOMAINS);
|
|
139
|
+
|
|
140
|
+
const CATALOG_HOST = "api.us.socrata.com";
|
|
141
|
+
const CATALOG_URL = `https://${CATALOG_HOST}/api/catalog/v1`;
|
|
142
|
+
|
|
143
|
+
// A valid Socrata 4x4 is EXACTLY 9 chars: 4 lowercase-alnum, a hyphen, 4 more.
|
|
144
|
+
// `.length === 9` (not just the regex) rejects a trailing `\n` (M2) that the
|
|
145
|
+
// regex `$` alone would admit ("abcd-1234\n" passes /…$/ in JS).
|
|
146
|
+
const DATASET_ID_RE = /^[a-z0-9]{4}-[a-z0-9]{4}$/;
|
|
147
|
+
|
|
148
|
+
// D2 — an AGGREGATE $select projection. Matches a SoQL aggregate function
|
|
149
|
+
// (count/sum/avg/min/max) applied via `fn(` — the `\b…\s*\(` shape avoids false
|
|
150
|
+
// hits on column names like `max_temperature` (no paren) or `xmax(` (no word
|
|
151
|
+
// boundary) — OR an explicit `group by`/`$group`. Case-insensitive. When the
|
|
152
|
+
// caller's own $select is aggregate, the count(*) companion is skipped (its
|
|
153
|
+
// raw-row total would be false for aggregate result rows). See `query`.
|
|
154
|
+
const AGGREGATE_SELECT_RE =
|
|
155
|
+
/\b(?:count|sum|avg|min|max)\s*\(|\bgroup\s+by\b|\$group\b/i;
|
|
156
|
+
|
|
157
|
+
// ─── HONESTY-CRITICAL coercions (null, never 0, for absent) ───────
|
|
158
|
+
// `num`/`str` are the shared, audited null-never-0 coercions in ./coerce.js
|
|
159
|
+
// (imported above, `num` re-exported): null/undefined, the literal "null",
|
|
160
|
+
// ""/whitespace (Number("") is 0!), and "(-)"/"-" all become null (never 0).
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The optional app-token header (keyless-first). Present ONLY when
|
|
164
|
+
* SOCRATA_APP_TOKEN is set; the value is never logged / never in `_meta`.
|
|
165
|
+
*/
|
|
166
|
+
function appTokenHeader(): Record<string, string> {
|
|
167
|
+
const t = process.env.SOCRATA_APP_TOKEN;
|
|
168
|
+
return t ? { "X-App-Token": t } : {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** true iff an app token is configured (for the `_meta` note — never the value). */
|
|
172
|
+
function appTokenPresent(): boolean {
|
|
173
|
+
return !!process.env.SOCRATA_APP_TOKEN;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── fetch layer ──────────────────────────────────────────────────
|
|
177
|
+
export type SocrataRow = Record<string, unknown>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* GET one SODA resource. SSRF guard (belt-and-suspenders behind the server's
|
|
181
|
+
* Zod enum+regex): domain ∈ allowlist, datasetId is a 9-char 4x4, and the
|
|
182
|
+
* CONSTRUCTED URL's hostname === domain (protocol https). Sets `redirect:"error"`
|
|
183
|
+
* (B1) + the optional app-token header, and reuses errors.ts retry/timeout/
|
|
184
|
+
* taxonomy (429 → rate_limited retryable; 5xx → upstream_unavailable; 404 →
|
|
185
|
+
* not_found; 400 → invalid_input). Returns the parsed JSON (unknown; callers
|
|
186
|
+
* validate the shape).
|
|
187
|
+
*/
|
|
188
|
+
async function getSocrataResource(
|
|
189
|
+
domain: string,
|
|
190
|
+
datasetId: string,
|
|
191
|
+
params: URLSearchParams,
|
|
192
|
+
): Promise<unknown> {
|
|
193
|
+
if (!SOCRATA_DOMAIN_SET.has(domain)) {
|
|
194
|
+
throw new ToolErrorCarrier({
|
|
195
|
+
kind: "invalid_input",
|
|
196
|
+
message: `Socrata domain ${JSON.stringify(domain)} is not on the curated allowlist. Allowed: ${SOCRATA_DOMAINS.join(", ")}.`,
|
|
197
|
+
retryable: false,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (datasetId.length !== 9 || !DATASET_ID_RE.test(datasetId)) {
|
|
201
|
+
throw new ToolErrorCarrier({
|
|
202
|
+
kind: "invalid_input",
|
|
203
|
+
message: `Invalid Socrata datasetId ${JSON.stringify(datasetId)} — expected a 4x4 id matching [a-z0-9]{4}-[a-z0-9]{4} (exactly 9 chars).`,
|
|
204
|
+
retryable: false,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const url = `https://${domain}/resource/${datasetId}.json?${params.toString()}`;
|
|
208
|
+
// Belt-and-suspenders: the FIXED path leaves only domain+datasetId to
|
|
209
|
+
// interpolate; assert the built URL cannot have been steered off-host.
|
|
210
|
+
const built = new URL(url);
|
|
211
|
+
if (built.hostname !== domain || built.protocol !== "https:") {
|
|
212
|
+
throw new ToolErrorCarrier({
|
|
213
|
+
kind: "invalid_input",
|
|
214
|
+
message: `Constructed Socrata URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted domain ${JSON.stringify(domain)} over https — refusing to fetch (SSRF safety).`,
|
|
215
|
+
retryable: false,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Shared fetch envelope (ADR-0005): init === { headers, redirect, signal } —
|
|
219
|
+
// byte-identical to the prior hand-rolled fetch.
|
|
220
|
+
// B1 — redirect:"error": a 3xx off an allowlisted host is anomalous for a
|
|
221
|
+
// direct-JSON SODA endpoint; error out rather than silently follow it
|
|
222
|
+
// off-allowlist (cf. attachments.ts finalHost precedent).
|
|
223
|
+
// m7 — the label is host-only; it surfaces verbatim in
|
|
224
|
+
// ToolError.upstreamEndpoint to the MCP caller and must NEVER include the
|
|
225
|
+
// app-token value.
|
|
226
|
+
return getJson(url, {
|
|
227
|
+
label: "socrata:" + domain,
|
|
228
|
+
headers: appTokenHeader(),
|
|
229
|
+
redirect: "error",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* GET the discovery catalog (fixed host api.us.socrata.com; `domains` bound to
|
|
235
|
+
* the allowlist enum by the caller). Same redirect/token/timeout policy.
|
|
236
|
+
*/
|
|
237
|
+
async function getCatalog(params: URLSearchParams): Promise<unknown> {
|
|
238
|
+
const url = `${CATALOG_URL}?${params.toString()}`;
|
|
239
|
+
const built = new URL(url);
|
|
240
|
+
if (built.hostname !== CATALOG_HOST || built.protocol !== "https:") {
|
|
241
|
+
throw new ToolErrorCarrier({
|
|
242
|
+
kind: "invalid_input",
|
|
243
|
+
message: `Constructed Socrata catalog URL host ${JSON.stringify(built.hostname)} does not match ${CATALOG_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
244
|
+
retryable: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Shared fetch envelope (ADR-0005) — same redirect:"error" (B1) + host-only
|
|
248
|
+
// label (m7, never the token) as getSocrataResource.
|
|
249
|
+
return getJson(url, {
|
|
250
|
+
label: "socrata:catalog",
|
|
251
|
+
headers: appTokenHeader(),
|
|
252
|
+
redirect: "error",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── map + meta helpers ───────────────────────────────────────────
|
|
257
|
+
const STRING_COERCION_NOTE =
|
|
258
|
+
"Row value fields arrive as strings verbatim from SODA (e.g. \"13650.00\", \"1\") — parse client-side. A missing value is absent, never 0.";
|
|
259
|
+
|
|
260
|
+
const SOURCE_SUFFIX = "via Socrata SODA (keyless)";
|
|
261
|
+
|
|
262
|
+
/** The count(*) companion outcome (m4): a real total, or a null with a reason. */
|
|
263
|
+
type CountOutcome =
|
|
264
|
+
| { total: number; reason: "ok" }
|
|
265
|
+
| { total: null; reason: "transient" } // fetch threw (network/5xx/timeout)
|
|
266
|
+
| { total: null; reason: "drift" }; // HTTP 200 but not `[{count:"<num>"}]`
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The count(*) companion (m4). Reuses the caller's `$where`/`$q`, drops
|
|
270
|
+
* `$select/$order/$limit/$offset`, sets `$select=count(*)`. BEST-EFFORT: ANY
|
|
271
|
+
* failure → total:null with a reason (transient throw vs 200-wrong-shape) — the
|
|
272
|
+
* caller keeps the rows and never fakes a total. A hard schema_drift throw is
|
|
273
|
+
* NEVER raised here (reserved for the PRIMARY row/catalog query).
|
|
274
|
+
*/
|
|
275
|
+
async function fetchCount(
|
|
276
|
+
domain: string,
|
|
277
|
+
datasetId: string,
|
|
278
|
+
where?: string,
|
|
279
|
+
q?: string,
|
|
280
|
+
): Promise<CountOutcome> {
|
|
281
|
+
const params = new URLSearchParams();
|
|
282
|
+
if (where) params.set("$where", where);
|
|
283
|
+
if (q) params.set("$q", q);
|
|
284
|
+
params.set("$select", "count(*)");
|
|
285
|
+
let body: unknown;
|
|
286
|
+
try {
|
|
287
|
+
body = await getSocrataResource(domain, datasetId, params);
|
|
288
|
+
} catch {
|
|
289
|
+
return { total: null, reason: "transient" };
|
|
290
|
+
}
|
|
291
|
+
// Expected shape: a single-row array [{ count: "<number-as-string>" }].
|
|
292
|
+
if (
|
|
293
|
+
Array.isArray(body) &&
|
|
294
|
+
body.length === 1 &&
|
|
295
|
+
body[0] != null &&
|
|
296
|
+
typeof body[0] === "object" &&
|
|
297
|
+
"count" in (body[0] as Record<string, unknown>)
|
|
298
|
+
) {
|
|
299
|
+
const c = num((body[0] as Record<string, unknown>).count);
|
|
300
|
+
if (c !== null) return { total: c, reason: "ok" };
|
|
301
|
+
}
|
|
302
|
+
return { total: null, reason: "drift" };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Tool 1: socrata_query ────────────────────────────────────────
|
|
306
|
+
/**
|
|
307
|
+
* Query rows from an allowlisted Socrata SODA dataset. The workhorse: reaches
|
|
308
|
+
* every spend/checkbook/contract/vendor-payment/E-rate dataset on any
|
|
309
|
+
* allowlisted portal. Rows pass through verbatim (value fields are strings).
|
|
310
|
+
*
|
|
311
|
+
* HONESTY: SODA's row response carries NO total, so (default) a count(*)
|
|
312
|
+
* companion supplies `totalAvailable`; if it fails, the rows still return with
|
|
313
|
+
* `totalAvailable:null` + a disclosing note (§m4), and `hasMore` is inferred
|
|
314
|
+
* from the page fill (§B2 — never a false complete). Genuine-empty (`[]` + count
|
|
315
|
+
* 0) → honest complete:true/totalAvailable:0; an outage/5xx/timeout/400/404 on
|
|
316
|
+
* the ROW query THROWS (never a fake empty).
|
|
317
|
+
*/
|
|
318
|
+
export async function query(args: {
|
|
319
|
+
domain: SocrataDomain;
|
|
320
|
+
datasetId: string;
|
|
321
|
+
select?: string;
|
|
322
|
+
where?: string;
|
|
323
|
+
order?: string;
|
|
324
|
+
q?: string;
|
|
325
|
+
limit?: number;
|
|
326
|
+
offset?: number;
|
|
327
|
+
withTotal?: boolean;
|
|
328
|
+
}): Promise<MetaBundle> {
|
|
329
|
+
const limit = args.limit ?? 100;
|
|
330
|
+
const offset = args.offset ?? 0;
|
|
331
|
+
const withTotal = args.withTotal ?? true;
|
|
332
|
+
|
|
333
|
+
// ── Primary row query (a shape violation here is a hard schema_drift). ──
|
|
334
|
+
const rowParams = new URLSearchParams();
|
|
335
|
+
if (args.select) rowParams.set("$select", args.select);
|
|
336
|
+
if (args.where) rowParams.set("$where", args.where);
|
|
337
|
+
if (args.order) rowParams.set("$order", args.order);
|
|
338
|
+
if (args.q) rowParams.set("$q", args.q);
|
|
339
|
+
rowParams.set("$limit", String(limit));
|
|
340
|
+
rowParams.set("$offset", String(offset));
|
|
341
|
+
const body = await getSocrataResource(args.domain, args.datasetId, rowParams);
|
|
342
|
+
if (!Array.isArray(body)) {
|
|
343
|
+
throw driftError(
|
|
344
|
+
"socrata:" + args.domain,
|
|
345
|
+
`socrata:${args.domain}/${args.datasetId} returned an unexpected shape (SODA /resource/{4x4}.json must be a JSON array of rows).`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const rows = body as SocrataRow[];
|
|
349
|
+
const returned = rows.length;
|
|
350
|
+
|
|
351
|
+
// ── count(*) companion (best-effort; §m4). ──
|
|
352
|
+
// D2 — aggregate-projection guard. When the caller's OWN $select is an
|
|
353
|
+
// AGGREGATE projection (count/sum/avg/min/max, or a group-by), the result
|
|
354
|
+
// rows are AGGREGATES, not raw records — so a count(*) companion (which
|
|
355
|
+
// counts the RAW underlying rows) would report a FALSE total. E.g. a 1-row
|
|
356
|
+
// `$select=count(*)` result would get totalAvailable = raw-row-count,
|
|
357
|
+
// hasMore:true, nextOffset:1 → an agent pages forever over a 1-row result
|
|
358
|
+
// (a false-pagination livelock). When detected we SKIP the companion
|
|
359
|
+
// entirely, set totalAvailable:null (an aggregate has no meaningful raw-row
|
|
360
|
+
// total), and let hasMore fall out of PAGE-FULLNESS below (§B2), never a
|
|
361
|
+
// bogus total. A non-aggregate query keeps the exact-count behavior.
|
|
362
|
+
const isAggregateSelect =
|
|
363
|
+
args.select !== undefined && AGGREGATE_SELECT_RE.test(args.select);
|
|
364
|
+
|
|
365
|
+
let totalAvailable: number | null = null;
|
|
366
|
+
let countNote: string;
|
|
367
|
+
if (isAggregateSelect) {
|
|
368
|
+
countNote =
|
|
369
|
+
"The $select is an aggregate/group-by projection (count/sum/avg/min/max or group by), so its result rows are aggregates — an aggregate has no meaningful raw-row total. totalAvailable is null and pagination is page-fullness-based; the count(*) companion was NOT issued (it would count the RAW underlying rows and report a FALSE total).";
|
|
370
|
+
} else if (withTotal) {
|
|
371
|
+
const outcome = await fetchCount(args.domain, args.datasetId, args.where, args.q);
|
|
372
|
+
if (outcome.reason === "ok") {
|
|
373
|
+
totalAvailable = outcome.total;
|
|
374
|
+
countNote =
|
|
375
|
+
"totalAvailable was resolved via a count(*) companion query (SODA's row response carries no total).";
|
|
376
|
+
} else if (outcome.reason === "transient") {
|
|
377
|
+
countNote =
|
|
378
|
+
"SODA's row response carries no total; the count(*) companion failed (transient); total unknown — results may be truncated at $limit (page via $offset).";
|
|
379
|
+
} else {
|
|
380
|
+
countNote =
|
|
381
|
+
"SODA's row response carries no total; the count(*) companion returned an unexpected shape (possible upstream API change); total unknown — results may be truncated at $limit (page via $offset).";
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
countNote =
|
|
385
|
+
"withTotal:false — the count(*) companion was skipped; total unknown — results may be truncated at $limit (page via $offset).";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── B2 — hasMore MUST NOT short-circuit to false on an unknown total. ──
|
|
389
|
+
const hasMore =
|
|
390
|
+
totalAvailable !== null
|
|
391
|
+
? offset + returned < totalAvailable
|
|
392
|
+
: returned >= limit;
|
|
393
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
394
|
+
|
|
395
|
+
const filtersApplied: string[] = [];
|
|
396
|
+
if (args.select) filtersApplied.push("select");
|
|
397
|
+
if (args.where) filtersApplied.push("where");
|
|
398
|
+
if (args.order) filtersApplied.push("order");
|
|
399
|
+
if (args.q) filtersApplied.push("q");
|
|
400
|
+
|
|
401
|
+
const notes: string[] = [
|
|
402
|
+
countNote,
|
|
403
|
+
`App token: ${appTokenPresent() ? "present (X-App-Token sent; value never logged)" : "absent (keyless; a free SOCRATA_APP_TOKEN lifts shared-IP 429 limits)"}.`,
|
|
404
|
+
STRING_COERCION_NOTE,
|
|
405
|
+
];
|
|
406
|
+
if (totalAvailable === null && !hasMore) {
|
|
407
|
+
notes.push(
|
|
408
|
+
"totalAvailable is unknown but completeness is INFERRED from a short page (returned < $limit): SODA returns fewer than $limit only when the result set is exhausted.",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return withMeta(
|
|
413
|
+
{ domain: args.domain, datasetId: args.datasetId, rows },
|
|
414
|
+
{
|
|
415
|
+
source: `${args.domain} ${SOURCE_SUFFIX}`,
|
|
416
|
+
keylessMode: true,
|
|
417
|
+
returned,
|
|
418
|
+
totalAvailable,
|
|
419
|
+
filtersApplied,
|
|
420
|
+
filtersDropped: [],
|
|
421
|
+
fieldsUnavailable: [],
|
|
422
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
423
|
+
notes,
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Tool 2: socrata_discover_datasets ────────────────────────────
|
|
429
|
+
export type CatalogDataset = {
|
|
430
|
+
id: string | null;
|
|
431
|
+
name: string | null;
|
|
432
|
+
description: string | null;
|
|
433
|
+
domain: string | null;
|
|
434
|
+
updatedAt: string | null;
|
|
435
|
+
link: string | null;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** Map one catalog `results[]` entry → a stable discovery row. */
|
|
439
|
+
function mapCatalogRow(row: unknown): CatalogDataset {
|
|
440
|
+
const r = (row ?? {}) as {
|
|
441
|
+
resource?: Record<string, unknown>;
|
|
442
|
+
metadata?: { domain?: unknown };
|
|
443
|
+
permalink?: unknown;
|
|
444
|
+
link?: unknown;
|
|
445
|
+
};
|
|
446
|
+
const res = r.resource ?? {};
|
|
447
|
+
return {
|
|
448
|
+
id: str(res.id),
|
|
449
|
+
name: str(res.name),
|
|
450
|
+
description: str(res.description),
|
|
451
|
+
domain: str(r.metadata?.domain),
|
|
452
|
+
updatedAt: str(res.updatedAt),
|
|
453
|
+
link: str(r.link) ?? str(r.permalink),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Discover dataset 4x4 ids via the Socrata catalog (memoized ~10 min). Omitting
|
|
459
|
+
* `domain` searches the WHOLE allowlist (repeated `domains=`); passing one scopes
|
|
460
|
+
* to it. Returns `[{ id, name, description, domain, updatedAt, link }]` +
|
|
461
|
+
* `totalAvailable = resultSetSize`. Feeds `datasetId` to socrata_query.
|
|
462
|
+
*
|
|
463
|
+
* m3 — this is the catalog's PRIMARY response: a non-number `resultSetSize` is a
|
|
464
|
+
* hard schema_drift throw (nothing valid to return), never a fabricated total.
|
|
465
|
+
* (Note: the catalog does not index every allowlisted host — e.g. USAC — so a
|
|
466
|
+
* host may return 0 here yet still be queryable via socrata_query with a known
|
|
467
|
+
* 4x4.)
|
|
468
|
+
*/
|
|
469
|
+
export async function discoverDatasets(args: {
|
|
470
|
+
q: string;
|
|
471
|
+
domain?: SocrataDomain;
|
|
472
|
+
limit?: number;
|
|
473
|
+
}): Promise<MetaBundle> {
|
|
474
|
+
const limit = args.limit ?? 20;
|
|
475
|
+
const params = new URLSearchParams();
|
|
476
|
+
params.set("q", args.q);
|
|
477
|
+
params.set("only", "datasets");
|
|
478
|
+
params.set("limit", String(limit));
|
|
479
|
+
if (args.domain) {
|
|
480
|
+
params.append("domains", args.domain);
|
|
481
|
+
} else {
|
|
482
|
+
for (const d of SOCRATA_DOMAINS) params.append("domains", d);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const key = `socrata:catalog:${args.domain ?? "*"}:${args.q}:${limit}`;
|
|
486
|
+
const { totalAvailable, results } = await memoize(
|
|
487
|
+
key,
|
|
488
|
+
async () => {
|
|
489
|
+
const body = await getCatalog(params);
|
|
490
|
+
const b = (body ?? {}) as { results?: unknown; resultSetSize?: unknown };
|
|
491
|
+
// m3 — hard drift on the PRIMARY response (contrast the best-effort count).
|
|
492
|
+
// The check stays INSIDE the memoize callback so a bad shape is never
|
|
493
|
+
// cached as a success (ADR-0005 v2 test 2).
|
|
494
|
+
if (typeof b.resultSetSize !== "number") {
|
|
495
|
+
throw driftError(
|
|
496
|
+
"socrata:catalog",
|
|
497
|
+
"socrata:catalog returned an unexpected shape (resultSetSize must be a number).",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const total = num(b.resultSetSize);
|
|
501
|
+
const rows = Array.isArray(b.results) ? b.results.map(mapCatalogRow) : [];
|
|
502
|
+
return { totalAvailable: total, results: rows };
|
|
503
|
+
},
|
|
504
|
+
10 * 60 * 1000,
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const returned = results.length;
|
|
508
|
+
const scope = args.domain ? `domain ${args.domain}` : `the ${SOCRATA_DOMAINS.length}-host allowlist`;
|
|
509
|
+
const notes: string[] = [
|
|
510
|
+
`Catalog search over ${scope} (only=datasets). Feed a result's id to socrata_query as datasetId.`,
|
|
511
|
+
`App token: ${appTokenPresent() ? "present (X-App-Token sent; value never logged)" : "absent (keyless)"}.`,
|
|
512
|
+
];
|
|
513
|
+
if (totalAvailable !== null && returned < totalAvailable) {
|
|
514
|
+
notes.push(
|
|
515
|
+
`Showing ${returned} of ${totalAvailable} matches; raise limit (≤100) or narrow q for the rest.`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return withMeta(
|
|
520
|
+
{ query: args.q, domain: args.domain ?? null, results },
|
|
521
|
+
{
|
|
522
|
+
source: `${CATALOG_HOST} catalog ${SOURCE_SUFFIX}`,
|
|
523
|
+
keylessMode: true,
|
|
524
|
+
returned,
|
|
525
|
+
totalAvailable,
|
|
526
|
+
filtersApplied: args.domain ? ["q", "domain"] : ["q"],
|
|
527
|
+
filtersDropped: [],
|
|
528
|
+
fieldsUnavailable: [],
|
|
529
|
+
notes,
|
|
530
|
+
} satisfies Partial<ResponseMeta>,
|
|
531
|
+
);
|
|
532
|
+
}
|