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/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 here always means the source was reached and genuinely matched nothing.`,
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
- if (index.bySlug.has(toolName)) return { ok: true, slug: toolName, input: args || {}, maxItems: undefined };
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 { ok: true, slug, input: (args && args.input) || {}, maxItems: args && args.maxItems };
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),