gov-data-mcp 1.2.4 → 1.2.6
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 +27 -7
- package/package.json +2 -2
- package/src/catalog.json +7253 -126
- package/src/index.js +3 -3
- package/src/tools.js +101 -6
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;
|
|
@@ -130,6 +202,11 @@ export function priceLine(pricing) {
|
|
|
130
202
|
const cheapest = pricing.tierDiscountsUsdPerUnit
|
|
131
203
|
? Math.min(...Object.values(pricing.tierDiscountsUsdPerUnit))
|
|
132
204
|
: pricing.usdPerUnit;
|
|
205
|
+
if (pricing.perResult === false) {
|
|
206
|
+
// Billed per discrete event (e.g. one report), not per row: a per-1,000 figure would mislead.
|
|
207
|
+
const lower = cheapest < pricing.usdPerUnit ? ` Lower on paid Apify plans, down to $${cheapest}.` : '';
|
|
208
|
+
return `$${pricing.usdPerUnit} per ${pricing.unit}.${lower} Any underlying screeners it calls bill their own rows separately.`;
|
|
209
|
+
}
|
|
133
210
|
const discount = cheapest < pricing.usdPerUnit
|
|
134
211
|
? ` Lower on paid Apify plans, down to $${(cheapest * 1000).toFixed(2)} per 1,000.`
|
|
135
212
|
: '';
|
|
@@ -199,7 +276,7 @@ export function featuredToolDefinitions(index) {
|
|
|
199
276
|
const routing = routingNoteFor(index, slug);
|
|
200
277
|
return {
|
|
201
278
|
name: toolNameFor(slug),
|
|
202
|
-
description: `${a.title}. ${truncate(a.description, 400)}${routing} Reads live from the official government source. ${costNote(a.pricing)} Store page: ${a.storeUrl}`,
|
|
279
|
+
description: `${a.title}. ${truncate(a.description, 400)}${routing} Reads live from the official government source. Call ${META_TOOLS.DESCRIBE} (free) for a verified example input. ${costNote(a.pricing)} Store page: ${a.storeUrl}`,
|
|
203
280
|
inputSchema: a.inputSchema,
|
|
204
281
|
annotations: { title: a.title, ...ANNOTATIONS.BILLED_LIVE_READ },
|
|
205
282
|
};
|
|
@@ -237,7 +314,7 @@ export function metaToolDefinitions(index) {
|
|
|
237
314
|
{
|
|
238
315
|
name: META_TOOLS.RUN,
|
|
239
316
|
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. `
|
|
240
|
-
+ `${COST_NOTE} A run that FAILS returns an error and no rows rather than an empty result, so a zero-row answer
|
|
317
|
+
+ `${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.`,
|
|
241
318
|
annotations: { title: 'Run any government data tool', ...ANNOTATIONS.BILLED_LIVE_READ },
|
|
242
319
|
inputSchema: {
|
|
243
320
|
type: 'object',
|
|
@@ -277,7 +354,9 @@ export function searchCatalog(index, query, limit = 10) {
|
|
|
277
354
|
categories: x.actor.categories,
|
|
278
355
|
// Price rides along on every search hit so an agent can weigh cost while
|
|
279
356
|
// choosing, rather than discovering it only after it has already called.
|
|
280
|
-
usdPer1000Results: x.actor.pricing ? x.actor.pricing.usdPer1000 : null,
|
|
357
|
+
usdPer1000Results: x.actor.pricing && x.actor.pricing.perResult !== false ? x.actor.pricing.usdPer1000 : null,
|
|
358
|
+
usdPerUnit: x.actor.pricing ? x.actor.pricing.usdPerUnit : null,
|
|
359
|
+
priceUnit: x.actor.pricing ? x.actor.pricing.unit : null,
|
|
281
360
|
storeUrl: x.actor.storeUrl,
|
|
282
361
|
}));
|
|
283
362
|
}
|
|
@@ -301,13 +380,25 @@ export function describeTool(index, name) {
|
|
|
301
380
|
? { ...actor.pricing, summary: priceLine(actor.pricing) }
|
|
302
381
|
: null,
|
|
303
382
|
storeUrl: actor.storeUrl,
|
|
383
|
+
must_supply: actor.mustSupply || [],
|
|
384
|
+
example_input: actor.exampleInput || null,
|
|
385
|
+
examples: actor.examples || [],
|
|
386
|
+
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. '
|
|
387
|
+
+ `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` : ''}.`,
|
|
304
388
|
inputSchema: actor.inputSchema,
|
|
305
389
|
};
|
|
306
390
|
}
|
|
307
391
|
|
|
308
392
|
/** Resolve a tool call to { slug, input } or an error, for both featured and meta RUN calls. */
|
|
309
393
|
export function resolveCall(index, toolName, args) {
|
|
310
|
-
|
|
394
|
+
const checked = (slug, input, maxItems) => {
|
|
395
|
+
const problems = preflight(index.bySlug.get(slug), input);
|
|
396
|
+
if (problems.length) {
|
|
397
|
+
return { ok: false, error: JSON.stringify({ tool: slug, run_status: 'NOT_RUN_INPUT_INVALID', problems, ...guidanceFor(index.bySlug.get(slug), 'preflight') }, null, 2) };
|
|
398
|
+
}
|
|
399
|
+
return { ok: true, slug, input, maxItems };
|
|
400
|
+
};
|
|
401
|
+
if (index.bySlug.has(toolName)) return checked(toolName, args || {}, undefined);
|
|
311
402
|
if (toolName === META_TOOLS.RUN) {
|
|
312
403
|
const slug = args && args.tool;
|
|
313
404
|
if (!slug) return { ok: false, error: `${META_TOOLS.RUN} requires a "tool" argument naming which catalog tool to run.` };
|
|
@@ -315,7 +406,7 @@ export function resolveCall(index, toolName, args) {
|
|
|
315
406
|
const near = searchCatalog(index, String(slug).replace(/[-_]/g, ' '), 5).map(r => r.tool);
|
|
316
407
|
return { ok: false, error: `No tool named "${slug}" in this catalog.${near.length ? ` Closest matches: ${near.join(', ')}.` : ''}${index.scopeNote || ''}` };
|
|
317
408
|
}
|
|
318
|
-
return
|
|
409
|
+
return checked(slug, (args && args.input) || {}, args && args.maxItems);
|
|
319
410
|
}
|
|
320
411
|
// A caller with a hardcoded pre-1.1 name gets told what happened rather than
|
|
321
412
|
// silently aliased, so the hardcode gets fixed instead of quietly persisting.
|
|
@@ -326,7 +417,7 @@ export function resolveCall(index, toolName, args) {
|
|
|
326
417
|
}
|
|
327
418
|
|
|
328
419
|
/** Shape a run result into MCP text content. Never collapses a failure into an empty answer. */
|
|
329
|
-
export function formatRunResult(slug, result) {
|
|
420
|
+
export function formatRunResult(slug, result, actor = null) {
|
|
330
421
|
const header = {
|
|
331
422
|
tool: slug,
|
|
332
423
|
run_status: result.status,
|
|
@@ -336,6 +427,10 @@ export function formatRunResult(slug, result) {
|
|
|
336
427
|
};
|
|
337
428
|
if (result.statusMessage) header.note = result.statusMessage;
|
|
338
429
|
const failed = result.status !== 'SUCCEEDED';
|
|
430
|
+
// A run still in flight on Apify is not a wrong input; do not tell the agent to change it.
|
|
431
|
+
const pending = result.status === 'CLIENT_TIMEOUT' || result.status === 'DATASET_UNSETTLED';
|
|
432
|
+
if (actor && failed && !pending) Object.assign(header, guidanceFor(actor, 'failed'));
|
|
433
|
+
if (actor && !failed && result.itemCount === 0) Object.assign(header, guidanceFor(actor, 'zero'));
|
|
339
434
|
return {
|
|
340
435
|
isError: failed,
|
|
341
436
|
text: JSON.stringify(failed ? header : { ...header, rows: result.items }, null, 2),
|