gov-data-mcp 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle Maloney (malonestar)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # gov-data-mcp
2
+
3
+ **95 US government open-data tools, as one MCP server.**
4
+
5
+ EPA, FEMA, USGS, NOAA, FAA, USACE, FDIC, HUD, NRCS, HRSA, CMS, county assessor rolls and state licensing boards — all reachable as agent-callable tools, all reading directly from official government APIs and bulk files. No scraping, no HTML parsing, no rate-limit roulette.
6
+
7
+ `mcp-name: io.github.malonestar/gov-data-mcp`
8
+
9
+ ```bash
10
+ npx gov-data-mcp
11
+ ```
12
+
13
+ ## Install
14
+
15
+ You need a free Apify API token from [console.apify.com/settings/integrations](https://console.apify.com/settings/integrations). Apify's free tier includes monthly platform credit that covers evaluating every tool here.
16
+
17
+ ### Claude Desktop / Claude Code / Cursor
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "gov-data": {
23
+ "command": "npx",
24
+ "args": ["-y", "gov-data-mcp"],
25
+ "env": { "APIFY_TOKEN": "apify_api_..." }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Claude Desktop reads `claude_desktop_config.json`; Claude Code reads `.mcp.json` in your project; Cursor reads `.cursor/mcp.json`. The block is identical in all three.
32
+
33
+ ## What you get
34
+
35
+ **12 dedicated tools** for the highest-traffic questions, callable directly:
36
+
37
+ | Tool | Answers |
38
+ |---|---|
39
+ | `site-due-diligence-bundle` | 20-layer go / caution / no-go verdict for one coordinate |
40
+ | `epa-contaminated-site-screener` | Phase I ESA database search at ASTM E1527-21 distances |
41
+ | `faa-drone-airspace-checker` | Part 107 LAANC and airspace verdicts, batched |
42
+ | `hifld-grid-proximity-screener` | Nearest transmission line, substation, serving utility, ISO/RTO |
43
+ | `interconnection-queue-tracker` | Generator interconnection queues across 7 ISOs |
44
+ | `fdic-ncua-health-rollup` | Bank and credit-union health with real peer cohorts |
45
+ | `fema-nri-county-risk-profile` | County and census-tract natural-hazard risk |
46
+ | `fws-wetlands-proximity-screener` | NWI wetlands within a radius, with decode columns |
47
+ | `nhd-surface-water-404-screener` | Clean Water Act §404 surface-water screen |
48
+ | `epa-drinking-water-quality-screener` | SDWA violations, lead, PFAS occurrence |
49
+ | `parcel-owner-lookup` | Assessor-roll owner of record for an address |
50
+ | `license-verifier` | 19 professional licensing boards across 9 states + OIG exclusions |
51
+
52
+ **Plus three tools that reach the other 83:**
53
+
54
+ - `search_gov_data_tools` — find a tool by keyword, agency or topic
55
+ - `describe_gov_data_tool` — full input schema for any tool in the catalog
56
+ - `run_gov_data_tool` — run any tool in the catalog
57
+
58
+ The catalog is bundled, so discovery costs nothing. Ask your agent *"what government data tools do you have for flood risk?"* and it will search all 95.
59
+
60
+ ## Example prompts
61
+
62
+ > Screen 1200 Broadway, Denver CO for environmental risk under ASTM E1527-21 and tell me which findings fall inside the standard's search distance.
63
+
64
+ > I'm siting a 40 MW solar project at 41.88, -93.10. Check grid proximity, prime farmland, wetlands, critical habitat and the interconnection queue, then tell me what would kill the project.
65
+
66
+ > Can I fly a Part 107 mission at these six coordinates, and which ones need a DroneZone authorization rather than LAANC?
67
+
68
+ > Which Texas banks show the 2006 CRE-concentration guidance flagged on both prongs?
69
+
70
+ ## How it works
71
+
72
+ Each tool is a published [Apify Actor](https://apify.com/malonestar) that this server invokes through the Apify API. The server starts the run, waits for a terminal state, and returns the rows.
73
+
74
+ **Billing** is to your own Apify account at each actor's published pay-per-result rate, listed on its Store page. Free-tier credit covers evaluation. A run that fails bills nothing beyond a fractional actor-start charge.
75
+
76
+ Every result carries the `run_id` and a `console.apify.com` URL, so any claim an agent makes from this server can be traced back to the exact run that produced it.
77
+
78
+ ## On honest answers
79
+
80
+ These actors are built around one rule: **a failure must never be presentable as "nothing was found."** That distinction matters most in exactly the cases people use this for — telling a buyer a property is clear of contamination, telling a pilot an airspace is uncontrolled, telling a lender a borrower is unlicensed.
81
+
82
+ So this server:
83
+
84
+ - reports `run_status` on every call, and never attaches a `rows` key to a run that did not succeed
85
+ - distinguishes *"the run SUCCEEDED and the source genuinely matched nothing"* from *"the run failed"* in the response text, explicitly
86
+ - retries transient Apify 429/5xx, then fails loudly saying the platform failed and no conclusion should be drawn about the government source
87
+ - treats an unknown tool name as a catalog miss, never as an empty result
88
+
89
+ The underlying actors carry the same discipline: per-source status on every row, `null` meaning *not checked* rather than *checked and negative*, live drift assertions that fail the run when an upstream silently truncates, and pagination guards on sources that answer HTTP 200 with a partial payload. Each actor's Store page documents the specific traps it defends against.
90
+
91
+ ## Coverage
92
+
93
+ Environmental & contamination · flood, wildfire, seismic, landslide, karst & sea-level rise · wetlands, critical habitat, protected lands & historic places · energy siting, grid, pipelines & interconnection · farmland, soils & water rights · banking, credit unions & fair lending · securities, auditors & pension plans · professional licensing & exclusion screening · real-estate leads, parcels, deeds & distress signals · drone airspace, bridges, tunnels & dams.
94
+
95
+ Run `search_gov_data_tools` with any of those terms for the full list, or browse [apify.com/malonestar](https://apify.com/malonestar).
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ npm install
101
+ npm test # offline suite, no network
102
+ node tools/mutate.cjs # re-injects known defects, asserts the suite catches them
103
+ npm run catalog -- <apify-token> # regenerate src/catalog.json from the live API
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "gov-data-mcp",
3
+ "version": "1.0.0",
4
+ "mcpName": "io.github.malonestar/gov-data-mcp",
5
+ "description": "MCP server exposing 95 US government open-data tools (EPA, FEMA, USGS, NOAA, FAA, USACE, FDIC, HUD, state licensing boards) as agent-callable tools, backed by published Apify Actors.",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "bin": {
9
+ "gov-data-mcp": "src/index.js"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "scripts": {
20
+ "start": "node src/index.js",
21
+ "test": "node --test test/tools.test.js test/apify.test.js test/catalog.test.js",
22
+ "catalog": "node tools/gen-catalog.cjs",
23
+ "check:catalog": "node tools/check-catalog-live.cjs"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "modelcontextprotocol",
28
+ "mcp-server",
29
+ "government-data",
30
+ "open-data",
31
+ "epa",
32
+ "fema",
33
+ "usgs",
34
+ "noaa",
35
+ "faa",
36
+ "fdic",
37
+ "environmental",
38
+ "due-diligence",
39
+ "phase-i-esa",
40
+ "real-estate",
41
+ "compliance",
42
+ "apify"
43
+ ],
44
+ "author": "malonestar <57780205+malonestar@users.noreply.github.com>",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/malonestar/gov-data-mcp.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/malonestar/gov-data-mcp/issues"
52
+ },
53
+ "homepage": "https://github.com/malonestar/gov-data-mcp#readme",
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.30.0"
56
+ }
57
+ }
package/src/apify.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Apify API client.
3
+ *
4
+ * `fetch` is injected so every branch below is reachable from the offline test
5
+ * suite. Nothing in this file may reach the network on import.
6
+ *
7
+ * DESIGN NOTE — why this polls instead of using run-sync-get-dataset-items:
8
+ * the sync endpoint hands back dataset rows without an unambiguous run status,
9
+ * so an actor that FAILED and an actor that legitimately matched nothing can
10
+ * arrive at the caller looking identical. Both are then reported to an agent as
11
+ * "no results", which is a confident negative the run never actually verified.
12
+ * Polling costs one extra request and makes the distinction explicit.
13
+ */
14
+
15
+ const API = 'https://api.apify.com/v2';
16
+
17
+ export const TERMINAL_STATES = ['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'];
18
+
19
+ export class ApifyError extends Error {
20
+ constructor(message, { status = null, runId = null, retryable = false } = {}) {
21
+ super(message);
22
+ this.name = 'ApifyError';
23
+ this.status = status;
24
+ this.runId = runId;
25
+ this.retryable = retryable;
26
+ }
27
+ }
28
+
29
+ export function createClient({ token, fetchImpl = globalThis.fetch, sleep = ms => new Promise(r => setTimeout(r, ms)), now = () => Date.now() }) {
30
+ if (!token) throw new ApifyError('APIFY_TOKEN is not set. Create a token at https://console.apify.com/settings/integrations and set APIFY_TOKEN in your MCP client config.');
31
+
32
+ async function request(pathname, { method = 'GET', body, attempt = 1 } = {}) {
33
+ const res = await fetchImpl(API + pathname, {
34
+ method,
35
+ headers: {
36
+ Authorization: 'Bearer ' + token,
37
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
38
+ },
39
+ ...(body ? { body: JSON.stringify(body) } : {}),
40
+ });
41
+
42
+ // 429 and 5xx are transient. Everything else is an answer, including 4xx.
43
+ if (res.status === 429 || res.status >= 500) {
44
+ if (attempt >= 4) {
45
+ throw new ApifyError(`Apify API ${method} ${pathname} failed with HTTP ${res.status} after ${attempt} attempts. This is an Apify platform problem, not a data problem — no conclusion should be drawn about the underlying government source.`, { status: res.status, retryable: true });
46
+ }
47
+ await sleep(500 * 2 ** (attempt - 1));
48
+ return request(pathname, { method, body, attempt: attempt + 1 });
49
+ }
50
+
51
+ let json = null;
52
+ const text = await res.text();
53
+ try { json = text ? JSON.parse(text) : null; } catch {
54
+ throw new ApifyError(`Apify API ${method} ${pathname} returned HTTP ${res.status} with a non-JSON body (first 200 chars: ${text.slice(0, 200)}).`, { status: res.status });
55
+ }
56
+
57
+ if (!res.ok) {
58
+ const detail = json && json.error ? `${json.error.type}: ${json.error.message}` : `HTTP ${res.status}`;
59
+ throw new ApifyError(`Apify API ${method} ${pathname} failed — ${detail}`, { status: res.status });
60
+ }
61
+
62
+ // TWO ENVELOPE SHAPES, and assuming one costs you the other silently.
63
+ // Most Apify v2 endpoints wrap their payload as { data: ... }, but
64
+ // /datasets/<id>/items returns a BARE JSON ARRAY. Reading `.data` off that
65
+ // array yields undefined, which downstream reads as "no rows" — a confident
66
+ // empty result produced by a request that actually succeeded. Measured
67
+ // 2026-08-11: this made every dataset read return [] forever while the
68
+ // dataset's own itemCount correctly reported 1.
69
+ if (Array.isArray(json)) return json;
70
+ return json && Object.prototype.hasOwnProperty.call(json, 'data') ? json.data : json;
71
+ }
72
+
73
+ /**
74
+ * Read a dataset that has just been written, without believing an early zero.
75
+ *
76
+ * MEASURED 2026-08-11: a karst screener run reached SUCCEEDED, the dataset read
77
+ * one millisecond later returned [], and the same dataset held 1 row (65 fields,
78
+ * in_karst_terrain: true) moments afterwards. Both `itemCount` and the items
79
+ * endpoint lag the run's terminal state, and they lag TOGETHER — so a single
80
+ * sample is not evidence. Reporting that first read would have told the caller
81
+ * the source "genuinely matched nothing" about a site that is in carbonate karst.
82
+ *
83
+ * The delay is INTERMITTENT, not a fixed cost: measured on the same actor and
84
+ * the same dataset endpoint, one run was readable 0 ms after finishing and
85
+ * another was still unreadable at 21 s while its own itemCount already said 1.
86
+ * A cache-busting parameter made no difference, so this is genuine propagation,
87
+ * not a stale edge cache. Hence a generous ceiling — a premature give-up wastes
88
+ * a run the caller has already paid for.
89
+ *
90
+ * A zero is only accepted after `minReads` independent reads spanning at least
91
+ * `minWindowMs`, all of which agreed that both signals were zero.
92
+ */
93
+ async function settleDataset(datasetId, maxItems, { minReads = 3, minWindowMs = 4000, maxWaitMs = 60000, gapMs = 2000 } = {}) {
94
+ const started = now();
95
+ let reads = 0;
96
+ let lastCount = 0;
97
+ while (true) {
98
+ const [meta, items] = await Promise.all([
99
+ request(`/datasets/${datasetId}`),
100
+ request(`/datasets/${datasetId}/items?limit=${maxItems}&clean=true`),
101
+ ]);
102
+ const rows = Array.isArray(items) ? items : [];
103
+ // Take the max of both signals; either one being non-zero disproves "empty".
104
+ lastCount = Math.max(rows.length, (meta && meta.itemCount) || 0);
105
+ reads += 1;
106
+ if (rows.length > 0) return { ok: true, items: rows };
107
+
108
+ const elapsed = now() - started;
109
+ if (lastCount === 0 && reads >= minReads && elapsed >= minWindowMs) {
110
+ return { ok: true, items: [] };
111
+ }
112
+ if (elapsed >= maxWaitMs) {
113
+ if (lastCount > 0) {
114
+ return {
115
+ ok: false,
116
+ message: `The run SUCCEEDED and its dataset reports ${lastCount} row(s), but the rows were still not readable after ${Math.round(elapsed / 1000)}s. This is an Apify dataset-propagation delay, not an empty result — retry the call, or read the dataset directly. No conclusion should be drawn about the underlying source.`,
117
+ };
118
+ }
119
+ return { ok: true, items: [] };
120
+ }
121
+ await sleep(gapMs);
122
+ }
123
+ }
124
+
125
+ return {
126
+ request,
127
+
128
+ /**
129
+ * Start a run, wait for a terminal state, and return rows.
130
+ * Resolves with { status, runId, items, itemCount, datasetId, statusMessage }.
131
+ * A non-SUCCEEDED terminal state RESOLVES rather than throwing so the caller
132
+ * can report the distinction between "failed" and "matched nothing" verbatim.
133
+ */
134
+ async runActor(actorId, input, { waitSecs = 300, pollMs = 2000, maxItems = 200 } = {}) {
135
+ const slug = actorId.replace('/', '~');
136
+ const run = await request(`/acts/${slug}/runs`, { method: 'POST', body: input });
137
+ const runId = run.id;
138
+
139
+ const deadline = now() + waitSecs * 1000;
140
+ let status = run.status;
141
+ let current = run;
142
+ while (!TERMINAL_STATES.includes(status)) {
143
+ if (now() > deadline) {
144
+ return {
145
+ status: 'CLIENT_TIMEOUT',
146
+ runId,
147
+ items: [],
148
+ itemCount: 0,
149
+ datasetId: current.defaultDatasetId || null,
150
+ statusMessage: `The run did not reach a terminal state within ${waitSecs}s. It is still running on Apify — check https://console.apify.com/actors/runs/${runId}. No result is available yet; this is NOT evidence that the source returned nothing.`,
151
+ };
152
+ }
153
+ await sleep(pollMs);
154
+ current = await request(`/actor-runs/${runId}`);
155
+ status = current.status;
156
+ }
157
+
158
+ const datasetId = current.defaultDatasetId;
159
+ if (status !== 'SUCCEEDED') {
160
+ return {
161
+ status,
162
+ runId,
163
+ items: [],
164
+ itemCount: 0,
165
+ datasetId,
166
+ statusMessage: `The run terminated with status ${status}${current.statusMessage ? ` — ${current.statusMessage}` : ''}. These actors are built to fail loudly rather than emit a misleading empty result, so treat this as "the question was not answered", never as "nothing was found".`,
167
+ };
168
+ }
169
+
170
+ const settled = await settleDataset(datasetId, maxItems);
171
+ if (!settled.ok) {
172
+ return {
173
+ status: 'DATASET_UNSETTLED',
174
+ runId,
175
+ items: [],
176
+ itemCount: 0,
177
+ datasetId,
178
+ statusMessage: settled.message,
179
+ };
180
+ }
181
+ const rows = settled.items;
182
+ return {
183
+ status,
184
+ runId,
185
+ items: rows,
186
+ itemCount: rows.length,
187
+ datasetId,
188
+ statusMessage: rows.length === 0
189
+ ? 'The run SUCCEEDED and the actor emitted zero rows. For these actors that is a real answer — the query was well-formed, the source was reached, and it genuinely matched nothing.'
190
+ : null,
191
+ };
192
+ },
193
+ };
194
+ }