gov-data-mcp 1.2.5 → 1.2.7
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/README.md +21 -1
- package/package.json +2 -2
- package/src/catalog.json +7079 -148
- package/src/index.js +3 -3
- package/src/tools.js +136 -5
package/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
19
|
|
|
20
20
|
import { createClient, ApifyError } from './apify.js';
|
|
21
|
-
import { indexCatalog, listTools, searchCatalog, describeTool, resolveCall, formatRunResult, META_TOOLS } from './tools.js';
|
|
21
|
+
import { indexCatalog, listTools, searchCatalog, describeTool, resolveCall, formatRunResult, serverInstructions, META_TOOLS } from './tools.js';
|
|
22
22
|
import { parseCliArgs, resolvePreset, describePresets, PRESET_ENV } from './presets.js';
|
|
23
23
|
|
|
24
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -51,7 +51,7 @@ const identity = chosen.preset
|
|
|
51
51
|
|
|
52
52
|
const server = new Server(
|
|
53
53
|
identity,
|
|
54
|
-
{ capabilities: { tools: {} } },
|
|
54
|
+
{ capabilities: { tools: {} }, instructions: serverInstructions(index) },
|
|
55
55
|
);
|
|
56
56
|
|
|
57
57
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(index) }));
|
|
@@ -82,7 +82,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
82
82
|
const result = await client.runActor(`${catalog.owner}/${call.slug}`, call.input, {
|
|
83
83
|
maxItems: call.maxItems ?? 200,
|
|
84
84
|
});
|
|
85
|
-
const formatted = formatRunResult(call.slug, result);
|
|
85
|
+
const formatted = formatRunResult(call.slug, result, index.bySlug.get(call.slug));
|
|
86
86
|
return text(formatted.text, formatted.isError);
|
|
87
87
|
} catch (err) {
|
|
88
88
|
if (err instanceof ApifyError) return text(`${err.message}`, true);
|
package/src/tools.js
CHANGED
|
@@ -54,6 +54,78 @@ export const RENAMED_IN_1_1 = {
|
|
|
54
54
|
run_gov_data_tool: 'run-gov-data-tool',
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Server-level guide, sent in the MCP `initialize` response as `instructions`.
|
|
59
|
+
*
|
|
60
|
+
* Written because the measured failure mode is agents GUESSING inputs: an agent
|
|
61
|
+
* that calls a screener with no state, no coordinates and no name starts a
|
|
62
|
+
* metered run that refuses the question (or matches nothing) and costs the
|
|
63
|
+
* caller an actor-start fee for no answer. Every rule below targets that.
|
|
64
|
+
*/
|
|
65
|
+
export function serverInstructions(index) {
|
|
66
|
+
const n = index.actors.length;
|
|
67
|
+
return [
|
|
68
|
+
`gov-data-mcp: ${n} tools over official US government open data (EPA, FEMA, USGS, FDIC, SEC, state licensing boards and registries, ...).${index.scopeNote || ''}`,
|
|
69
|
+
'',
|
|
70
|
+
'HOW TO CALL THESE TOOLS WELL',
|
|
71
|
+
`1. Find the tool: ${META_TOOLS.SEARCH} (free). 2. Read it: ${META_TOOLS.DESCRIBE} (free) - it returns mustSupply, exampleInput and run-verified examples. 3. Run it.`,
|
|
72
|
+
'4. Start from exampleInput or one of the examples and change only the values you need. These inputs are verified to return rows; a hand-built input usually is not.',
|
|
73
|
+
'5. Always SCOPE the question: a state, county, coordinates, a name, an NPI, a date window. Unscoped calls are refused by most tools or bill for whole national lists.',
|
|
74
|
+
'6. Use real values the source knows: two-letter state codes unless the schema says otherwise, decimal-degree lat/lon, ISO dates (YYYY-MM-DD). Never invent enum values - use the enum in the schema.',
|
|
75
|
+
'7. Keep the first call small (low maxResults / few assets) and widen once the shape works. Each call bills per row (see the price in the tool description).',
|
|
76
|
+
'',
|
|
77
|
+
'READING RESULTS',
|
|
78
|
+
'- run_status other than SUCCEEDED means the question was NOT answered. Read `note` (the tool explains what was wrong), fix the input, and do not retry the identical input.',
|
|
79
|
+
'- SUCCEEDED with zero rows means the source was reached and matched nothing for exactly that input. Before concluding "none exist", check the scope (radius, date window, spelling, state).',
|
|
80
|
+
'- null in a field means "not checked / not published", never "no". Many rows carry per-source status fields; read them before stating a negative.',
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Free, local pre-flight check run BEFORE a metered call: required fields the
|
|
86
|
+
* caller must supply (required with no server default) and closed vocabularies.
|
|
87
|
+
* Apify would reject these too, but only after the agent has learned nothing
|
|
88
|
+
* useful; answering here returns the example to copy instead.
|
|
89
|
+
*/
|
|
90
|
+
export function preflight(actor, input) {
|
|
91
|
+
const problems = [];
|
|
92
|
+
const props = (actor.inputSchema && actor.inputSchema.properties) || {};
|
|
93
|
+
const given = input && typeof input === 'object' ? input : {};
|
|
94
|
+
for (const k of actor.mustSupply || []) {
|
|
95
|
+
const v = given[k];
|
|
96
|
+
if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) problems.push(`"${k}" is required and was not supplied`);
|
|
97
|
+
}
|
|
98
|
+
for (const [k, v] of Object.entries(given)) {
|
|
99
|
+
const p = props[k];
|
|
100
|
+
if (!p) { problems.push(`"${k}" is not an input of this tool (known inputs: ${Object.keys(props).join(', ')})`); continue; }
|
|
101
|
+
if (Array.isArray(p.enum) && v !== undefined && v !== null && !p.enum.includes(v)) problems.push(`"${k}" must be one of ${JSON.stringify(p.enum)}, got ${JSON.stringify(v)}`);
|
|
102
|
+
const itemEnum = p.items && Array.isArray(p.items.enum) ? p.items.enum : null;
|
|
103
|
+
if (itemEnum && Array.isArray(v)) {
|
|
104
|
+
const bad = v.filter(x => !itemEnum.includes(x));
|
|
105
|
+
if (bad.length) problems.push(`"${k}" items must be from ${JSON.stringify(itemEnum)}, got ${JSON.stringify(bad)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return problems;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What an agent should do next, attached to every non-SUCCEEDED or zero-row result. */
|
|
112
|
+
export function guidanceFor(actor, kind) {
|
|
113
|
+
const ex = actor && actor.exampleInput ? actor.exampleInput : null;
|
|
114
|
+
const examples = actor && Array.isArray(actor.examples) ? actor.examples : [];
|
|
115
|
+
const base = kind === 'failed'
|
|
116
|
+
? 'The question was not answered and no result rows were billed. Read `note` above for the reason, fix the input, and do not resend the same input.'
|
|
117
|
+
: kind === 'preflight'
|
|
118
|
+
? 'Nothing was run and nothing was charged. Fix the input and call again.'
|
|
119
|
+
: 'Zero rows is an answer only for exactly this input. Before concluding nothing exists, check the scope: spelling, state code, radius, date window, mode.';
|
|
120
|
+
return {
|
|
121
|
+
next_step: `${base} Compare your input with example_input (verified to return rows) and change only the values you need.`,
|
|
122
|
+
must_supply: actor ? (actor.mustSupply || []) : [],
|
|
123
|
+
example_input: ex,
|
|
124
|
+
examples: examples.length ? examples : undefined,
|
|
125
|
+
describe: actor ? `${META_TOOLS.DESCRIBE} {"tool":"${actor.slug}"}` : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
57
129
|
/** MCP tool names must match ^[a-zA-Z0-9_-]{1,64}$. Slugs already do. */
|
|
58
130
|
export function toolNameFor(slug) {
|
|
59
131
|
return slug;
|
|
@@ -180,8 +252,51 @@ export const ROUTING = {
|
|
|
180
252
|
'epa-drinking-water-quality-screener':
|
|
181
253
|
'CHOOSE THIS for public water-system quality: SDWA violations, lead 90th-percentile results and PFAS occurrence. '
|
|
182
254
|
+ 'It is NOT a property contamination screen — for soil and groundwater records at a site use epa-contaminated-site-screener.',
|
|
255
|
+
'faa-drone-airspace-checker':
|
|
256
|
+
'CHOOSE THIS for drone (Part 107) airspace questions at specific US points: airspace class, LAANC ceiling, restricted and special-use areas, temporary flight restrictions. '
|
|
257
|
+
+ 'It does NOT screen ground conditions — for a site environmental or hazard screen use site-due-diligence-bundle. It reports what the FAA layers say; it does not request or grant an authorization.',
|
|
183
258
|
};
|
|
184
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Parameter guidance for the featured tools: which inputs matter, how they
|
|
262
|
+
* interact, and how to keep a first call small. Input schemas already document
|
|
263
|
+
* each field on its own; what they cannot say is which combinations an agent
|
|
264
|
+
* should reach for, which inputs override others, and which one is the cost cap.
|
|
265
|
+
* Every input named here in backticks is asserted to exist in the live schema
|
|
266
|
+
* (test/guidance.test.js), so a renamed field fails the build rather than
|
|
267
|
+
* leaving advice that points at nothing.
|
|
268
|
+
*/
|
|
269
|
+
export const SCOPING = {
|
|
270
|
+
'site-due-diligence-bundle':
|
|
271
|
+
'INPUTS: `assets` is required (lat/lon per site). `radiusMiles` sets the proximity layers only; point-in-polygon layers ignore it. `maxAssets` is the cost cap, one billed row per site, so start with 1-3 sites.',
|
|
272
|
+
'epa-contaminated-site-screener':
|
|
273
|
+
'INPUTS: `mode` decides everything else. In "assets" mode supply `assets` (address or lat/lon) and `radiusMiles`; `states`, `onlyNpl` and `onlyWithCoords` are ignored. In "inventory" mode supply `states` and the asset inputs are ignored. One row per site hit is billed, so `maxHitsPerProgram` and `maxResults` are the cost levers; keep them low on a first call near dense industrial areas.',
|
|
274
|
+
'faa-drone-airspace-checker':
|
|
275
|
+
'INPUTS: `points` is required. `layers` narrows which FAA layers are checked; leave it empty for all eight. `maxPoints` is the cost cap, one billed row per point.',
|
|
276
|
+
'hifld-grid-proximity-screener':
|
|
277
|
+
'INPUTS: `assets` is required. `radiusMiles` bounds every layer. `minVoltageKv` filters lines only, never substations or plants, and drops lines with unknown voltage. `includeSubstations`, `includePowerPlants` and `includeUtility` add columns, not rows; turn them off to speed up large batches. `maxResults` caps assets, one billed row each.',
|
|
278
|
+
'interconnection-queue-tracker':
|
|
279
|
+
'INPUTS: leave `isos` empty for all seven ISOs, or name one or two to keep a first call small. `maxResults` is applied in ISO order, so a low value silently drops the later ISOs; raise it or narrow `isos` instead. For change monitoring use `deltaOnly` in snapshot mode rather than re-pulling the full queue.',
|
|
280
|
+
'fdic-ncua-health-rollup':
|
|
281
|
+
'INPUTS: always set `state`; an empty state scores the whole country and bills far more rows. `minAssets` and `maxAssets` are in THOUSANDS of dollars (1000000 = $1B) and together with `peerBasis` define who counts as a peer. `institutionType` "credit_union" is not available yet and fails the run. `maxResults` is the cost cap.',
|
|
282
|
+
'fema-nri-county-risk-profile':
|
|
283
|
+
'INPUTS: supply `assets` (FIPS, state+county, or lat/lon) for specific places; `states` and `counties` are used ONLY when `assets` is empty (inventory mode). `resolution` "tract" applies only to lat/lon assets and inventory pulls; FIPS or state+county assets always get county data. `maxResults` caps rows in both modes.',
|
|
284
|
+
'fws-wetlands-proximity-screener':
|
|
285
|
+
'INPUTS: `assets` is required. `radiusMeters` is the presence check (a true circle); `nearestSearchRadiusMeters` is independent and only bounds the nearest-wetland distance, so a site can read no wetland in radius and still report a nearest one. `maxResults` caps assets, one billed row each.',
|
|
286
|
+
'nhd-surface-water-404-screener':
|
|
287
|
+
'INPUTS: `assets` is required. `radiusMeters` 1000 covers a typical Phase I adjacent-property review; widen it only on purpose. Keep `includeNonNetworkFlowlines` on for a conservative screen. `runBudgetSeconds` trades completeness for speed when the USGS service is slow; a site cut by the budget reports null counts, not zero. `maxResults` caps sites, exactly one row each.',
|
|
288
|
+
'epa-drinking-water-quality-screener':
|
|
289
|
+
'INPUTS: supply `assets` (lat/lon, matched to a water system boundary) or `pwsids` (9-character EPA system ids) or both. `includePfas` is the slow leg; turn it off when PFAS is not the question. `violationYears` only changes the recent-violation flags, not the history summary. `maxAssets` is the cost cap.',
|
|
290
|
+
'parcel-owner-lookup':
|
|
291
|
+
'INPUTS: `addresses` is required, one full street address with city and state per entry. Coverage is a fixed list of county and state assessor rolls; every address still yields exactly one row, so read its lookup_status before treating a missing owner as an answer. `maxResults` caps addresses, one billed row each.',
|
|
292
|
+
'license-verifier':
|
|
293
|
+
'INPUTS: always scope with `states` (a state code searches every board in it; a board id targets one). The most precise search is `licenseNumber`; otherwise `lastName` plus `firstName`. `roster` runs a batch instead of a single search, and `mode` "roster-delta" switches to the newly-credentialed feed with `professions` and `sinceDays`. `maxResults` applies PER BOARD, so a bare state code can bill several boards; start with a low value.',
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export function scopingNoteFor(slug) {
|
|
297
|
+
return SCOPING[slug] ? ` ${SCOPING[slug]}` : '';
|
|
298
|
+
}
|
|
299
|
+
|
|
185
300
|
/**
|
|
186
301
|
* A routing note is only shown when every tool it points at is reachable on
|
|
187
302
|
* THIS server. Under a preset, a note that sends the agent to a tool outside
|
|
@@ -204,7 +319,7 @@ export function featuredToolDefinitions(index) {
|
|
|
204
319
|
const routing = routingNoteFor(index, slug);
|
|
205
320
|
return {
|
|
206
321
|
name: toolNameFor(slug),
|
|
207
|
-
description: `${a.title}. ${truncate(a.description, 400)}${routing} Reads live from the official government source. ${costNote(a.pricing)} Store page: ${a.storeUrl}`,
|
|
322
|
+
description: `${a.title}. ${truncate(a.description, 400)}${routing}${scopingNoteFor(slug)} Reads live from the official government source. Call ${META_TOOLS.DESCRIBE} (free) for a verified example input. ${costNote(a.pricing)} Store page: ${a.storeUrl}`,
|
|
208
323
|
inputSchema: a.inputSchema,
|
|
209
324
|
annotations: { title: a.title, ...ANNOTATIONS.BILLED_LIVE_READ },
|
|
210
325
|
};
|
|
@@ -242,7 +357,7 @@ export function metaToolDefinitions(index) {
|
|
|
242
357
|
{
|
|
243
358
|
name: META_TOOLS.RUN,
|
|
244
359
|
description: `Run any one of the ${n} catalog tools with the given input and return its rows. Call ${META_TOOLS.DESCRIBE} first to shape the input. `
|
|
245
|
-
+ `${COST_NOTE} A run that FAILS returns an error and no rows rather than an empty result, so a zero-row answer
|
|
360
|
+
+ `${COST_NOTE} A run that FAILS returns an error and no rows rather than an empty result, so a zero-row answer means the source was reached and matched nothing for exactly that input. Failed and zero-row results carry next_step, must_supply and a verified example_input to copy. Inputs are checked locally first: a missing required field or an invalid enum value is reported free, without starting a run.`,
|
|
246
361
|
annotations: { title: 'Run any government data tool', ...ANNOTATIONS.BILLED_LIVE_READ },
|
|
247
362
|
inputSchema: {
|
|
248
363
|
type: 'object',
|
|
@@ -308,13 +423,25 @@ export function describeTool(index, name) {
|
|
|
308
423
|
? { ...actor.pricing, summary: priceLine(actor.pricing) }
|
|
309
424
|
: null,
|
|
310
425
|
storeUrl: actor.storeUrl,
|
|
426
|
+
must_supply: actor.mustSupply || [],
|
|
427
|
+
example_input: actor.exampleInput || null,
|
|
428
|
+
examples: actor.examples || [],
|
|
429
|
+
how_to_call: 'Start from example_input (or one of examples): these inputs are verified to return rows. Change only the values you need, keep the call scoped (state / coordinates / name / date window) and keep the first call small. '
|
|
430
|
+
+ `Run it with ${META_TOOLS.RUN} {"tool":"${actor.slug}","input":{...}}${index.featured && index.featured.includes(actor.slug) ? ` or call the dedicated "${actor.slug}" tool directly` : ''}.`,
|
|
311
431
|
inputSchema: actor.inputSchema,
|
|
312
432
|
};
|
|
313
433
|
}
|
|
314
434
|
|
|
315
435
|
/** Resolve a tool call to { slug, input } or an error, for both featured and meta RUN calls. */
|
|
316
436
|
export function resolveCall(index, toolName, args) {
|
|
317
|
-
|
|
437
|
+
const checked = (slug, input, maxItems) => {
|
|
438
|
+
const problems = preflight(index.bySlug.get(slug), input);
|
|
439
|
+
if (problems.length) {
|
|
440
|
+
return { ok: false, error: JSON.stringify({ tool: slug, run_status: 'NOT_RUN_INPUT_INVALID', problems, ...guidanceFor(index.bySlug.get(slug), 'preflight') }, null, 2) };
|
|
441
|
+
}
|
|
442
|
+
return { ok: true, slug, input, maxItems };
|
|
443
|
+
};
|
|
444
|
+
if (index.bySlug.has(toolName)) return checked(toolName, args || {}, undefined);
|
|
318
445
|
if (toolName === META_TOOLS.RUN) {
|
|
319
446
|
const slug = args && args.tool;
|
|
320
447
|
if (!slug) return { ok: false, error: `${META_TOOLS.RUN} requires a "tool" argument naming which catalog tool to run.` };
|
|
@@ -322,7 +449,7 @@ export function resolveCall(index, toolName, args) {
|
|
|
322
449
|
const near = searchCatalog(index, String(slug).replace(/[-_]/g, ' '), 5).map(r => r.tool);
|
|
323
450
|
return { ok: false, error: `No tool named "${slug}" in this catalog.${near.length ? ` Closest matches: ${near.join(', ')}.` : ''}${index.scopeNote || ''}` };
|
|
324
451
|
}
|
|
325
|
-
return
|
|
452
|
+
return checked(slug, (args && args.input) || {}, args && args.maxItems);
|
|
326
453
|
}
|
|
327
454
|
// A caller with a hardcoded pre-1.1 name gets told what happened rather than
|
|
328
455
|
// silently aliased, so the hardcode gets fixed instead of quietly persisting.
|
|
@@ -333,7 +460,7 @@ export function resolveCall(index, toolName, args) {
|
|
|
333
460
|
}
|
|
334
461
|
|
|
335
462
|
/** Shape a run result into MCP text content. Never collapses a failure into an empty answer. */
|
|
336
|
-
export function formatRunResult(slug, result) {
|
|
463
|
+
export function formatRunResult(slug, result, actor = null) {
|
|
337
464
|
const header = {
|
|
338
465
|
tool: slug,
|
|
339
466
|
run_status: result.status,
|
|
@@ -343,6 +470,10 @@ export function formatRunResult(slug, result) {
|
|
|
343
470
|
};
|
|
344
471
|
if (result.statusMessage) header.note = result.statusMessage;
|
|
345
472
|
const failed = result.status !== 'SUCCEEDED';
|
|
473
|
+
// A run still in flight on Apify is not a wrong input; do not tell the agent to change it.
|
|
474
|
+
const pending = result.status === 'CLIENT_TIMEOUT' || result.status === 'DATASET_UNSETTLED';
|
|
475
|
+
if (actor && failed && !pending) Object.assign(header, guidanceFor(actor, 'failed'));
|
|
476
|
+
if (actor && !failed && result.itemCount === 0) Object.assign(header, guidanceFor(actor, 'zero'));
|
|
346
477
|
return {
|
|
347
478
|
isError: failed,
|
|
348
479
|
text: JSON.stringify(failed ? header : { ...header, rows: result.items }, null, 2),
|