gov-data-mcp 1.2.5 → 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;
@@ -204,7 +276,7 @@ export function featuredToolDefinitions(index) {
204
276
  const routing = routingNoteFor(index, slug);
205
277
  return {
206
278
  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}`,
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}`,
208
280
  inputSchema: a.inputSchema,
209
281
  annotations: { title: a.title, ...ANNOTATIONS.BILLED_LIVE_READ },
210
282
  };
@@ -242,7 +314,7 @@ export function metaToolDefinitions(index) {
242
314
  {
243
315
  name: META_TOOLS.RUN,
244
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. `
245
- + `${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.`,
246
318
  annotations: { title: 'Run any government data tool', ...ANNOTATIONS.BILLED_LIVE_READ },
247
319
  inputSchema: {
248
320
  type: 'object',
@@ -308,13 +380,25 @@ export function describeTool(index, name) {
308
380
  ? { ...actor.pricing, summary: priceLine(actor.pricing) }
309
381
  : null,
310
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` : ''}.`,
311
388
  inputSchema: actor.inputSchema,
312
389
  };
313
390
  }
314
391
 
315
392
  /** Resolve a tool call to { slug, input } or an error, for both featured and meta RUN calls. */
316
393
  export function resolveCall(index, toolName, args) {
317
- 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);
318
402
  if (toolName === META_TOOLS.RUN) {
319
403
  const slug = args && args.tool;
320
404
  if (!slug) return { ok: false, error: `${META_TOOLS.RUN} requires a "tool" argument naming which catalog tool to run.` };
@@ -322,7 +406,7 @@ export function resolveCall(index, toolName, args) {
322
406
  const near = searchCatalog(index, String(slug).replace(/[-_]/g, ' '), 5).map(r => r.tool);
323
407
  return { ok: false, error: `No tool named "${slug}" in this catalog.${near.length ? ` Closest matches: ${near.join(', ')}.` : ''}${index.scopeNote || ''}` };
324
408
  }
325
- return { ok: true, slug, input: (args && args.input) || {}, maxItems: args && args.maxItems };
409
+ return checked(slug, (args && args.input) || {}, args && args.maxItems);
326
410
  }
327
411
  // A caller with a hardcoded pre-1.1 name gets told what happened rather than
328
412
  // silently aliased, so the hardcode gets fixed instead of quietly persisting.
@@ -333,7 +417,7 @@ export function resolveCall(index, toolName, args) {
333
417
  }
334
418
 
335
419
  /** Shape a run result into MCP text content. Never collapses a failure into an empty answer. */
336
- export function formatRunResult(slug, result) {
420
+ export function formatRunResult(slug, result, actor = null) {
337
421
  const header = {
338
422
  tool: slug,
339
423
  run_status: result.status,
@@ -343,6 +427,10 @@ export function formatRunResult(slug, result) {
343
427
  };
344
428
  if (result.statusMessage) header.note = result.statusMessage;
345
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'));
346
434
  return {
347
435
  isError: failed,
348
436
  text: JSON.stringify(failed ? header : { ...header, rows: result.items }, null, 2),