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 +21 -0
- package/README.md +108 -0
- package/package.json +57 -0
- package/src/apify.js +194 -0
- package/src/catalog.json +6627 -0
- package/src/index.js +77 -0
- package/src/tools.js +180 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gov-data-mcp — MCP server exposing 95 published US government open-data tools.
|
|
4
|
+
*
|
|
5
|
+
* Wiring only. All decision logic lives in src/tools.js (pure, offline-tested)
|
|
6
|
+
* and src/apify.js (fetch injected). A test asserts this file holds no logic
|
|
7
|
+
* beyond dispatch.
|
|
8
|
+
*/
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { createClient, ApifyError } from './apify.js';
|
|
17
|
+
import { indexCatalog, listTools, searchCatalog, describeTool, resolveCall, formatRunResult, META_TOOLS } from './tools.js';
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
|
|
21
|
+
const catalog = JSON.parse(readFileSync(join(here, 'catalog.json'), 'utf8'));
|
|
22
|
+
const index = indexCatalog(catalog);
|
|
23
|
+
|
|
24
|
+
const server = new Server(
|
|
25
|
+
{ name: 'gov-data-mcp', version: pkg.version },
|
|
26
|
+
{ capabilities: { tools: {} } },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(index) }));
|
|
30
|
+
|
|
31
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
32
|
+
const { name, arguments: args } = req.params;
|
|
33
|
+
const text = (s, isError = false) => ({ content: [{ type: 'text', text: s }], isError });
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
if (name === META_TOOLS.SEARCH) {
|
|
37
|
+
const results = searchCatalog(index, args?.query, args?.limit ?? 10);
|
|
38
|
+
if (results.length === 0) {
|
|
39
|
+
return text(`No tool in this catalog matches "${args?.query}". The catalog covers ${index.actors.length} US government data sources; this is a catalog miss, not a statement about whether the data exists.`);
|
|
40
|
+
}
|
|
41
|
+
return text(JSON.stringify({ query: args?.query, matches: results.length, results }, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (name === META_TOOLS.DESCRIBE) {
|
|
45
|
+
const d = describeTool(index, args?.tool);
|
|
46
|
+
return text(JSON.stringify(d, null, 2), !d.ok);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const call = resolveCall(index, name, args);
|
|
50
|
+
if (!call.ok) return text(call.error, true);
|
|
51
|
+
|
|
52
|
+
const token = process.env.APIFY_TOKEN || process.env.APIFY_API_TOKEN;
|
|
53
|
+
const client = createClient({ token });
|
|
54
|
+
const result = await client.runActor(`${catalog.owner}/${call.slug}`, call.input, {
|
|
55
|
+
maxItems: call.maxItems ?? 200,
|
|
56
|
+
});
|
|
57
|
+
const formatted = formatRunResult(call.slug, result);
|
|
58
|
+
return text(formatted.text, formatted.isError);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof ApifyError) return text(`${err.message}`, true);
|
|
61
|
+
return text(`gov-data-mcp failed to complete the call to "${name}": ${err.message}. No data was returned and no conclusion should be drawn about the underlying source.`, true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
if (index.missingFeatured.length) {
|
|
67
|
+
// Loud on stderr, never on stdout — stdout is the MCP transport.
|
|
68
|
+
console.error(`[gov-data-mcp] WARNING: featured tools missing from catalog: ${index.missingFeatured.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
await server.connect(new StdioServerTransport());
|
|
71
|
+
console.error(`[gov-data-mcp] ready — ${index.actors.length} government data tools (catalog generated ${catalog.generatedAt})`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch(err => {
|
|
75
|
+
console.error('[gov-data-mcp] fatal:', err);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure tool-surface logic. No I/O, no SDK imports — everything here is directly
|
|
3
|
+
* testable offline, and src/index.js is wiring only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Actors promoted to first-class MCP tools. The catalog holds 95 actors; handing
|
|
8
|
+
* an agent 95 tool definitions degrades tool selection and blows up the context
|
|
9
|
+
* of every request. These are the highest-signal ones (revenue-proven plus the
|
|
10
|
+
* flagships); the remaining 80 stay reachable through search/describe/run.
|
|
11
|
+
*/
|
|
12
|
+
export const FEATURED = [
|
|
13
|
+
'site-due-diligence-bundle',
|
|
14
|
+
'epa-contaminated-site-screener',
|
|
15
|
+
'faa-drone-airspace-checker',
|
|
16
|
+
'hifld-grid-proximity-screener',
|
|
17
|
+
'interconnection-queue-tracker',
|
|
18
|
+
'fdic-ncua-health-rollup',
|
|
19
|
+
'fema-nri-county-risk-profile',
|
|
20
|
+
'fws-wetlands-proximity-screener',
|
|
21
|
+
'nhd-surface-water-404-screener',
|
|
22
|
+
'epa-drinking-water-quality-screener',
|
|
23
|
+
'parcel-owner-lookup',
|
|
24
|
+
'license-verifier',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const META_TOOLS = {
|
|
28
|
+
SEARCH: 'search_gov_data_tools',
|
|
29
|
+
DESCRIBE: 'describe_gov_data_tool',
|
|
30
|
+
RUN: 'run_gov_data_tool',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** MCP tool names must match ^[a-zA-Z0-9_-]{1,64}$. Slugs already do. */
|
|
34
|
+
export function toolNameFor(slug) {
|
|
35
|
+
return slug;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function indexCatalog(catalog) {
|
|
39
|
+
const actors = catalog.actors || [];
|
|
40
|
+
const bySlug = new Map(actors.map(a => [a.slug, a]));
|
|
41
|
+
const missingFeatured = FEATURED.filter(s => !bySlug.has(s));
|
|
42
|
+
return { actors, bySlug, missingFeatured };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function truncate(s, n) {
|
|
46
|
+
if (!s) return '';
|
|
47
|
+
return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function featuredToolDefinitions(index) {
|
|
51
|
+
return FEATURED.filter(s => index.bySlug.has(s)).map(slug => {
|
|
52
|
+
const a = index.bySlug.get(slug);
|
|
53
|
+
return {
|
|
54
|
+
name: toolNameFor(slug),
|
|
55
|
+
description: `${a.title}. ${truncate(a.description, 400)} Reads live from the official government source. Store page: ${a.storeUrl}`,
|
|
56
|
+
inputSchema: a.inputSchema,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function metaToolDefinitions(index) {
|
|
62
|
+
const n = index.actors.length;
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
name: META_TOOLS.SEARCH,
|
|
66
|
+
description: `Search the full catalog of ${n} US government data tools by keyword, agency, or topic (e.g. "wetlands", "FDIC", "flood", "drone airspace", "business licenses"). Returns matching tool names with descriptions. Use this first when the task is not covered by one of the dedicated tools above.`,
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
query: { type: 'string', description: 'Keywords to match against tool name, title, description and category.' },
|
|
71
|
+
limit: { type: 'integer', description: 'Maximum number of results to return. Default 10.', minimum: 1, maximum: 50 },
|
|
72
|
+
},
|
|
73
|
+
required: ['query'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: META_TOOLS.DESCRIBE,
|
|
78
|
+
description: `Return the full input schema and documentation for any one of the ${n} tools in the catalog. Call this before ${META_TOOLS.RUN} so the input is correctly shaped.`,
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
tool: { type: 'string', description: 'The tool name, e.g. "noaa-slr-inundation-threshold-screener".' },
|
|
83
|
+
},
|
|
84
|
+
required: ['tool'],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: META_TOOLS.RUN,
|
|
89
|
+
description: `Run any one of the ${n} catalog tools with the given input and return its rows. Billed to your own Apify account at the tool's published rate.`,
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
tool: { type: 'string', description: 'The tool name to run, e.g. "usgs-seismic-design-screener".' },
|
|
94
|
+
input: { type: 'object', description: 'Input object matching the schema returned by describe_gov_data_tool.' },
|
|
95
|
+
maxItems: { type: 'integer', description: 'Maximum rows to return. Default 200.', minimum: 1, maximum: 1000 },
|
|
96
|
+
},
|
|
97
|
+
required: ['tool', 'input'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listTools(index) {
|
|
104
|
+
return [...featuredToolDefinitions(index), ...metaToolDefinitions(index)];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function searchCatalog(index, query, limit = 10) {
|
|
108
|
+
const terms = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
109
|
+
if (terms.length === 0) return [];
|
|
110
|
+
const scored = index.actors.map(a => {
|
|
111
|
+
const hay = `${a.slug} ${a.title} ${a.description} ${(a.categories || []).join(' ')}`.toLowerCase();
|
|
112
|
+
let score = 0;
|
|
113
|
+
for (const t of terms) {
|
|
114
|
+
if (a.slug.toLowerCase().includes(t)) score += 5;
|
|
115
|
+
else if (a.title.toLowerCase().includes(t)) score += 3;
|
|
116
|
+
else if (hay.includes(t)) score += 1;
|
|
117
|
+
}
|
|
118
|
+
return { actor: a, score };
|
|
119
|
+
}).filter(x => x.score > 0);
|
|
120
|
+
scored.sort((a, b) => b.score - a.score || a.actor.slug.localeCompare(b.actor.slug));
|
|
121
|
+
return scored.slice(0, limit).map(x => ({
|
|
122
|
+
tool: toolNameFor(x.actor.slug),
|
|
123
|
+
title: x.actor.title,
|
|
124
|
+
description: truncate(x.actor.description, 300),
|
|
125
|
+
categories: x.actor.categories,
|
|
126
|
+
storeUrl: x.actor.storeUrl,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function describeTool(index, name) {
|
|
131
|
+
const actor = index.bySlug.get(name);
|
|
132
|
+
if (!actor) {
|
|
133
|
+
const near = searchCatalog(index, String(name || '').replace(/[-_]/g, ' '), 5).map(r => r.tool);
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: `No tool named "${name}" in this catalog.${near.length ? ` Closest matches: ${near.join(', ')}.` : ''} Use ${META_TOOLS.SEARCH} to find one. This is a lookup miss, not a statement about the underlying data.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
tool: toolNameFor(actor.slug),
|
|
142
|
+
title: actor.title,
|
|
143
|
+
description: actor.description,
|
|
144
|
+
categories: actor.categories,
|
|
145
|
+
storeUrl: actor.storeUrl,
|
|
146
|
+
inputSchema: actor.inputSchema,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resolve a tool call to { slug, input } or an error, for both featured and meta RUN calls. */
|
|
151
|
+
export function resolveCall(index, toolName, args) {
|
|
152
|
+
if (index.bySlug.has(toolName)) return { ok: true, slug: toolName, input: args || {}, maxItems: undefined };
|
|
153
|
+
if (toolName === META_TOOLS.RUN) {
|
|
154
|
+
const slug = args && args.tool;
|
|
155
|
+
if (!slug) return { ok: false, error: `${META_TOOLS.RUN} requires a "tool" argument naming which catalog tool to run.` };
|
|
156
|
+
if (!index.bySlug.has(slug)) {
|
|
157
|
+
const near = searchCatalog(index, String(slug).replace(/[-_]/g, ' '), 5).map(r => r.tool);
|
|
158
|
+
return { ok: false, error: `No tool named "${slug}" in this catalog.${near.length ? ` Closest matches: ${near.join(', ')}.` : ''}` };
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, slug, input: (args && args.input) || {}, maxItems: args && args.maxItems };
|
|
161
|
+
}
|
|
162
|
+
return { ok: false, error: `Unknown tool "${toolName}".` };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Shape a run result into MCP text content. Never collapses a failure into an empty answer. */
|
|
166
|
+
export function formatRunResult(slug, result) {
|
|
167
|
+
const header = {
|
|
168
|
+
tool: slug,
|
|
169
|
+
run_status: result.status,
|
|
170
|
+
run_id: result.runId,
|
|
171
|
+
rows_returned: result.itemCount,
|
|
172
|
+
apify_run_url: result.runId ? `https://console.apify.com/actors/runs/${result.runId}` : null,
|
|
173
|
+
};
|
|
174
|
+
if (result.statusMessage) header.note = result.statusMessage;
|
|
175
|
+
const failed = result.status !== 'SUCCEEDED';
|
|
176
|
+
return {
|
|
177
|
+
isError: failed,
|
|
178
|
+
text: JSON.stringify(failed ? header : { ...header, rows: result.items }, null, 2),
|
|
179
|
+
};
|
|
180
|
+
}
|