In July 2026 I built a portfolio of pay-per-result Apify Actors around US government data: SAM.gov federal contract opportunities, the National Provider Identifier (NPI) registry of healthcare providers, building permits from 10 city open-data portals, Florida contractor licenses.
Then I asked Claude a question I'd normally answer with my own Actor: "find active cybersecurity solicitations set aside for small business." I watched it flail. It knew SAM.gov existed. It knew the data was public. It could not reach any of it. The official API wants a registered key. The search UI is not an API. The daily CSV extract runs roughly 240 MB, which is not a thing you hand a chat model mid-conversation.
That gap, an agent that knows where the answer lives but can't reach it, is what an Actor plus the Model Context Protocol (MCP) closes. MCP is the open standard that lets AI clients like Claude and Cursor call external tools. This article covers how I exposed my Actors to agents, the schema decisions that made the calls reliable, and the code from the small open-source MCP server I shipped: us-govdata-mcp.
Prerequisites
If you want to follow along and wrap your own Actor:
- Node.js 18 or newer (the server uses the built-in
fetch). - An Apify account and API token (free plan works).
- A published Actor (ideally pay-per-result, so agent calls map cleanly to charges).
- Any MCP client: Claude Desktop, Claude Code, or Cursor.
Why government data is hostile territory for agents
Every source my Actors wrap is officially public. Not one of them is usable by an agent out of the box. The failure modes are worth walking through one by one, because each of them shaped a design decision later.
SAM.gov publishes every federal contract opportunity. The documented API needs a registered key with rate limits. Type "sam.gov api" into Google and autocomplete finishes the sentence for you: "api key," "api limits," "rate limit." My SAM.gov Actor uses the site's own public search backend instead (no key) and enriches each notice with contracting-officer emails and phones from the detail endpoint. To be clear about what that means: this is public government-records data, requested at polite rates (the same calls the sam.gov site makes for any visitor, minus the clicking).
The NPI registry (NPPES), run by the Centers for Medicare & Medicaid Services, has a free API. It also silently caps any search at 1,200 records and then repeats its last page forever. Run the numbers: 1,200 records out of a registry of 8 million-plus is 0.015% of the data, served up as if it were everything. A human notices the duplicated results eventually. An agent takes the cap at face value and hands its user "1,200 dentists in Miami" with total confidence. My NPI Actor detects the cap and fans the query out by ZIP prefix automatically.
Florida's DBPR (Department of Business and Professional Regulation) publishes the full state license roll as CSV extracts, behind a content delivery network (CDN) that returns 403 to datacenter IPs. That one is quietly lethal. The agent's compute is a datacenter IP. The data is public and the front door is closed to the very callers we're discussing. (What those files did to my Actor once the connection was open is a story of its own; I told it in Tuesday's postmortem.)
City permit portals are 10 different schemas across 10 cities. Nothing an agent can learn once and reuse. Flattening that into one record shape is the whole reason my permits Actor exists.
The pattern is consistent: public data, real engineering tax. An Actor pays that tax once, on my side. The open question is how an agent finds and calls it.
The zero-effort path: Apify's own MCP server
Here's the part that cost me nothing: every published Actor is already callable by agents through Apify's MCP server at mcp.apify.com. An agent connected to it can discover Actors in the store and run them with the user's Apify token. When I published my Actors, I got an agent-facing API for free.
That matters for prioritization. If you have a published Actor and 30 spare seconds, you already have an MCP story. Your store listing title, description, and input schema become your tool documentation. (That realization sent me back to rewrite all of mine. More on schema design below.)
So why did I build a dedicated server anyway? Three reasons.
First, curation. A generic gateway offers an agent thousands of Actors. I wanted 3 named tools with tight descriptions, so a client configured with my server does exactly one job: search US government data.
Second, guardrails. My server sets defaults an agent won't think to set, like capping results at 25 per call so an exploratory question costs cents, not dollars.
Third, distribution. The server is an MIT-licensed repo with its own README, listed on the Glama MCP directory. Follow the money for a second: users bring their own Apify token, every tool call runs my Actors as a paid run on their own account, I never see their data, and I keep the per-result revenue. It's a funnel with the incentives printed on the outside.
The server: 800 lines, 3 tools, no state
The whole server is just over 800 lines of TypeScript across 4 files. Each tool validates its arguments with zod, builds the Actor input, and calls one Apify endpoint: run-sync-get-dataset-items. That endpoint starts the Actor, waits for it to finish, and returns the dataset items in one response, the right shape for a synchronous tool call. This is very simple, and it's supposed to be.
The core is small enough to show almost whole. This is the real client from src/apify.ts (trimmed of the dry-run branch and the ACTORS table):
const APIFY_API_BASE = "https://api.apify.com/v2";
export class ApifyClientError extends Error {}
export async function runActor(
actorKey: keyof typeof ACTORS,
input: Record<string, unknown>,
options: { timeoutSecs?: number } = {},
): Promise<{ items: unknown[] }> {
const actor = ACTORS[actorKey];
const timeoutSecs = options.timeoutSecs ?? 300;
const url =
`${APIFY_API_BASE}/acts/${actor.apiId}/run-sync-get-dataset-items` +
`?clean=true&format=json&timeout=${timeoutSecs}`;
const token = process.env.APIFY_TOKEN?.trim();
if (!token) throw new ApifyClientError(missingTokenMessage(actor));
const controller = new AbortController();
const killer = setTimeout(() => controller.abort(), (timeoutSecs + 30) * 1000);
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(input),
signal: controller.signal,
});
} catch (err) {
throw new ApifyClientError(
`Could not reach the Apify API (${err instanceof Error ? err.message : String(err)}). ` +
"Check your network connection and try again.",
);
} finally {
clearTimeout(killer);
}
if (!res.ok) {
// Status-specific messages (see "Errors an agent can act on" below).
throw new ApifyClientError(`Apify API error (HTTP ${res.status})`);
}
const items = (await res.json()) as unknown;
if (!Array.isArray(items)) {
throw new ApifyClientError(
"Unexpected response from the Apify API (expected a JSON array of dataset items).",
);
}
return { items };
}
Details that earn their keep:
- The token travels in an
Authorization: Bearerheader, never in the URL. URLs end up in logs. - The abort timer runs 30 seconds past the Apify-side timeout. Belt and suspenders against a hung socket.
- The response is checked for being an actual array before it's handed to the agent.
The server speaks two transports: stdio (what desktop clients spawn) and Streamable HTTP with a /healthz endpoint. Adding HTTP cost one ~50-line Express handler (stateless, a fresh server per request) and made the Docker deployment story real.
Schema design: writing for a reader that never skims
Here's the mental shift that took me longest, and I'll admit I only half understood it at first. A human user of my Actor sees a form in Apify Console, rendered from the input schema, and pokes at it until the output looks right. An agent gets one shot. It reads the JSON schema, constructs a call, and whatever happens next is my fault or my credit.
As such, every parameter description in the MCP server is written like documentation for a very literal junior developer. This is the real schema for the SAM.gov tool, abbreviated to the instructive parts:
const DATE = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format")
.describe("Date in YYYY-MM-DD format");
inputSchema: {
keyword: z.string().optional().describe(
"Case-insensitive match against notice title, solicitation number, and description, " +
"e.g. 'janitorial', 'cybersecurity', 'drone'.",
),
naicsCodes: z.array(z.string()).optional().describe(
"NAICS industry codes; prefixes work, e.g. '541511' or just '54' for all professional services.",
),
setAsides: z.array(z.string()).optional().describe(
"Small-business set-aside codes or label text, e.g. 'SBA' (total small business), " +
"'8A', 'WOSB', 'SDVOSBC', 'HZC' (HUBZone).",
),
popStates: z.array(z.string().length(2)).optional().describe(
"Two-letter place-of-performance state codes, e.g. ['TX', 'FL'].",
),
postedAfter: DATE.optional().describe(
"Only notices posted on/after this date (YYYY-MM-DD). Strongly recommended — makes runs much faster.",
),
maxResults: z.number().int().min(1).max(5000).default(25).describe(
"Max opportunities to return (1-5000, default 25). Each returned record is billed at " +
"$0.004 per opportunity.",
),
}
The rules I converged on:
Every description carries examples. Not "a North American Industry Classification System (NAICS) code" but "'541511' or just '54'". Agents pattern-match on examples far more reliably than on prose. The set-aside field lists the actual codes because an agent asked for "HUBZone contracts" needs the mapping to 'HZC' right there.
Enums over free text wherever the input space is closed. The permits tool takes cities as z.enum([...]) with 11 literal values, the 10 supported sources plus "all". An agent physically cannot ask for a city I don't support. In the Actor's own input schema, the same idea shows up as enum plus enumTitles: closed inputs get a fixed list of values with human-readable labels.
Dates are regex-validated with a corrective error message. "Use YYYY-MM-DD format" comes back to the agent on a bad date. It self-corrects on the next attempt. That corrective loop is the cheapest robustness you can buy.
Defaults are cost guardrails, not conveniences. Every tool defaults to 25 results. The Actors themselves default to 500, the right number for a human building a lead list and the wrong number for an agent answering "are there any solar permits in Austin?" So I did the arithmetic on what a default question should cost: 25 permits at $0.005 apiece is about $0.13, 25 contract notices ≈ $0.10, 25 providers ≈ $0.05. An exploratory question costs a dime. Nobody has to stop and think about a dime.
Unset optionals get stripped. A tiny helper removes undefined values so the Actor never receives "minValuation": undefined, which JSON-stringifies away silently in some paths and lands as a literal in others:
function compact(obj: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
);
}
The price is in the tool description itself. Each description ends with: runs the paid Actor at this store URL, on YOUR account, pay per result, charged only for records actually returned. That's partly ethics. The agent's user should never be surprised by a charge. It's also plain self-interest, because an agent that understands the pricing sets sane maxResults values. My smoke test literally asserts the disclosure exists:
assert.ok(
/pay per result/i.test(t.description),
`${t.name} description discloses pay-per-result pricing`,
);
Errors an agent can act on
My first instinct was to let errors throw and bubble up as protocol-level failures. That was wrong. A thrown error gives the agent a stack trace and nothing to do. MCP lets a tool return isError: true with text content instead. And the agent reads that text.
So every failure mode returns instructions:
export function missingTokenMessage(actor: ActorInfo): string {
return [
"APIFY_TOKEN is not set, so this tool cannot run yet. Setup takes ~2 minutes:",
"",
`1. Create a free Apify account: ${SIGN_UP_URL}`,
`2. Copy your API token: ${TOKEN_URL}`,
`3. Set APIFY_TOKEN in this MCP server's environment (see the README's client config examples) and restart your MCP client.`,
"",
`Note: this tool runs the paid Apify actor at ${actor.storeUrl}`,
`(pay per result: ${actor.pricing} — you are only charged for records actually returned,`
+ " and Apify's free plan includes monthly platform credit to start with).",
].join("\n");
}
When a user installs the server without a token, the first tool call doesn't crash. Claude relays a numbered setup guide with the sign-up link. The error message is onboarding.
The HTTP error paths get the same treatment. A 401 says "double-check APIFY_TOKEN" and links where to copy a fresh one. A 402 or 403 explains the account is likely out of platform credit and links the billing page and the Actor's pricing. A 404 says the Actor may have been renamed and links the store page. Each message answers the question the agent will be asked next: "so what do I tell the user?"
One deliberate choice: the server starts and lists its tools without a token. The token check happens at call time. A client shouldn't fail to boot because one server of five is unconfigured.
Dry-run mode: testing the agent path without spending anything
The hardest part of testing agent tooling is that real calls cost real money on someone's account. My answer is an APIFY_DRY_RUN=1 environment variable. In dry-run mode, a tool call makes no network request. It returns canned sample records (each flagged _dryRun: true) plus the exact Apify API request that would have been sent:
{
"resultCount": 1,
"results": [{ "noticeId": "dry0000000000000000000000000001", "title": "Cybersecurity Assessment and Continuous Monitoring Services (SAMPLE)", "setAside": "Total Small Business Set-Aside (FAR 19.5)", "naicsCode": "541512", "responseDeadline": "2026-07-31T17:00:00-04:00", "_dryRun": true }],
"dryRun": true,
"apifyRequest": {
"method": "POST",
"url": "https://api.apify.com/v2/acts/cblu~sam-gov-contract-opportunities-scraper/run-sync-get-dataset-items?clean=true&format=json&timeout=300",
"headers": { "Content-Type": "application/json", "Authorization": "Bearer <APIFY_TOKEN>" },
"body": { "keyword": "cybersecurity", "naicsCodes": ["541512"], "popStates": ["TX"], "activeOnly": true, "maxResults": 15 }
}
}
(For space, the sample record is cut to its instructive fields; the real payload also carries department, office, place of performance, and the full description.)
This one feature pays for itself three ways. The test suite runs the entire path (server boot, tool discovery, argument validation, input construction, both transports) with no Apify account and no network. Continuous integration (CI) stays free and deterministic. And anyone evaluating the server can point Claude at it and watch real tool calls happen, with an explicit note in the payload telling the agent these are samples.
The smoke test asserts the constructed request byte-for-byte: right endpoint, clean=true, bearer header, defaults applied, unset optionals absent. When I run npm test, every check passes or the build doesn't ship.
$ npm test
[1] stdio introspection without APIFY_TOKEN
us-govdata-mcp v0.1.0 running on stdio
note: APIFY_TOKEN is not set. Tool listing works, but tool calls will fail until you set it. Get a free token at https://console.apify.com/sign-up
PASS tools/list returns 3 tools: search_building_permits, search_federal_contract_opportunities, search_healthcare_providers
PASS every tool has a JSON Schema input and a pricing disclosure in its description
[2] tool call without APIFY_TOKEN returns a helpful error
PASS error explains the missing token, links sign-up + actor store page, states the price
[3] APIFY_DRY_RUN=1 exercises the full tool path with canned data
us-govdata-mcp v0.1.0 running on stdio
note: APIFY_DRY_RUN is set — tools return canned sample data (no Apify calls, no charges).
PASS search_building_permits: 2 sample record(s); request construction verified
PASS search_federal_contract_opportunities: 1 sample record(s); request construction verified
PASS search_healthcare_providers: 2 sample record(s); request construction verified
[4] Streamable HTTP transport introspection
PASS GET /healthz responds
PASS tools/list over Streamable HTTP returns the same 3 tools
PASS dry-run tool call works over Streamable HTTP
All checks passed (9).
What it looks like when an agent uses it
With the server configured in Claude Desktop:
{
"mcpServers": {
"us-govdata": {
"command": "npx",
"args": ["-y", "github:CBLU2005/us-govdata-mcp"],
"env": { "APIFY_TOKEN": "your_apify_token_here" }
}
}
}
…the prompts that used to require a browser session become tool calls:
-
"Find building permits for new construction over $500k issued in Austin this month, with contractor names." ‚Üí
search_building_permitswithcities: ["austin"],issuedAfter,minValuation: 500000. -
"Any active small-business set-aside cybersecurity solicitations due after today? Include the contracting officer's email." ‚Üí
search_federal_contract_opportunitieswithkeyword,setAsides,responseDueAfter, and the emails come back on the records. -
"List dentists in the greater Miami area with practice phone numbers." ‚Üí
search_healthcare_providerswithtaxonomyDescription: "Dentist",postalCode: "331*".
The wildcard in that last call is worth a sentence. The NPI Actor supports ZIP prefix wildcards precisely because "greater Miami" is not a ZIP code. The schema description spells out the pattern, '331*' matches all ZIPs starting 331 (greater Miami), and agents use it correctly because the example is their use case.
What I can honestly claim: the tool path is verified end to end by the test suite, the server is live on the Glama directory, and agent-originated runs land as ordinary paid runs on the calling user's account. What I can't claim: reliable telemetry on how often third-party agents call it. Apify shows me user and run counts per Actor, not per channel. Glama turned out to know more than I did. I claimed my listing while writing this, in late August 2026, and found a funnel waiting: 966 search impressions and 524 profile views in the trailing 30 days. And zero tool calls. Call it 500 developers opening the page and not one of them installing it.
That is a humbling number and a more useful one than the dashboard I assumed didn't exist. My distribution problem was never discovery. It is the account-and-token wall standing between a curious developer and their first call.
What I'd tell you to do differently
Start with the Apify MCP server, not a custom one. Publishing a well-schema'd Actor gets you agent reachability today. Build a dedicated server only when you want curation, defaults, or a distribution funnel you control.
Descriptions and defaults are where the leverage is. The transports took an afternoon. The schema descriptions took longer and matter more. Every hour spent adding examples to a field description repays itself in calls that don't fail.
Put prices where the agent can read them. Pay-per-result plus an honest description is a genuinely good interface between a user's budget and an agent's enthusiasm.
Build the dry-run first. I built it for CI and it turned out to be the demo, the docs, and the debugger.
Next steps
The repo is MIT-licensed at github.com/CBLU2005/us-govdata-mcp. Clone it, swap in your own Actor IDs, and you have a dedicated MCP server for your own portfolio in an evening. npm test proves the whole path with no account and no charges.
My own roadmap: expose the Florida license Actor's new monitoring mode as a fourth tool, so an agent can set up standing license-compliance alerts instead of one-off searches. And directory listings (mcp.so, PulseMCP) to test whether MCP directories drive measurable installs.
If you publish an Actor: your next thousand users may never see your store page. They'll be agents. Write your schemas like it.
Top comments (0)