I already had a working Apify Actor. A human could enter a company name, choose several states, and get normalized business registry records. Then I exposed it through the Apify MCP server and learned that “works in the Store” is not the same as “works as an agent tool.”
Agents amplify every ambiguous default. They omit fields, interpret empty datasets too confidently, and repeat expensive calls when the tool contract is vague. My first multi-state default could return 188 paid rows, take about 24.5 seconds, and cost roughly $0.3761. That was acceptable for an intentional bulk run. It was a bad surprise inside a conversation.
I changed the Actor around four constraints: a narrow schema, deterministic defaults, bounded first-run cost, and evidence an agent can cite. This article shows the exact Actor, MCP configuration, failures, and live result.
What I built
The Actor is US Business Entity Search. It searches official public business registries and normalizes records across 16 no-key states. California is optional and requires the caller's CALICO API key.
The implementation is in Go. Each state adapter talks to its government source, then maps the response into one BusinessEntity shape:
package ussearch
type Input struct {
SearchQuery string `json:"searchQuery"`
CAAPIKey string `json:"caApiKey"`
States []string `json:"states"`
MaxResults int `json:"maxResults"`
FetchDetails bool `json:"fetchDetails"`
}
type BusinessEntity struct {
EntityName string `json:"entityName"`
EntityID string `json:"entityId"`
State string `json:"state"`
EntityType string `json:"entityType"`
Status string `json:"status"`
FilingDate string `json:"filingDate"`
Jurisdiction string `json:"jurisdiction"`
RegisteredAgent string `json:"registeredAgent"`
Address *Address `json:"address"`
Officers []Officer `json:"officers"`
SourceURL string `json:"sourceUrl"`
SearchQuery string `json:"searchQuery"`
}
type Address struct {
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
Zip string `json:"zip,omitempty"`
}
type Officer struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
}
The adapters run concurrently. One state can be slow or unavailable without discarding valid evidence from the others. Every row keeps its state and official source URL.
That design worked for human buyers. MCP exposed the weak parts of the contract.
Failure one: my defaults were UI-friendly but agent-hostile
My original Store configuration selected every supported state. maxResults looked like a total cap, but the runtime applied it per state. A caller who saw maxResults: 25 could reasonably expect no more than 25 rows. The actual ceiling was 25 multiplied by the selected state count.
That mismatch produced the 188-row run. The Actor was healthy, but the first evaluation was expensive and slow. An agent could also retry because a 24-second tool call feels stuck.
I changed the default to one specific company across two representative states:
{
"searchQuery": "Goldman Sachs",
"states": ["NY", "TX"],
"maxResults": 5,
"fetchDetails": true
}
I also renamed the field in the Store UI to Max Results Per State and put the multiplication rule in its description. The default now returns at most ten paid rows. A verified run dropped from about 24.5 seconds to 2.75 seconds, while the other states remain selectable.
The important Apify input schema detail is that prefill and default are not interchangeable. prefill helps a person in the Console. default is the behavior when a schedule or agent omits the field. I aligned three places:
- The input schema
defaultvalues. - The Go fallback used for empty Actor input.
- The Actor's example input shown on its evaluation surface.
Here is the runtime fallback:
func defaultInput() Input {
return Input{
SearchQuery: "Goldman Sachs",
States: []string{"NY", "TX"},
MaxResults: 5,
FetchDetails: true,
}
}
If those values disagree, the same-looking tool can behave differently in the Store, on a schedule, and through MCP.
Failure two: vague schema names become silent agent errors
A human can notice that a field did nothing and correct it. An agent may confidently continue.
I have seen this outside this Actor when an MCP schema advertised defaultCountry while the handler consumed defaultRegion. Both names sounded plausible. The call returned HTTP 200, but the buyer's argument was ignored.
For this Actor, I made the JSON names boring and exact: searchQuery, states, maxResults, and fetchDetails. The input schema uses the same names as the Go tags. The descriptions answer the questions an agent cannot infer:
- Is the limit total or per state?
- Which states work without credentials?
- What does
fetchDetailsadd? - What happens if California is selected without a key?
The hosted Apify MCP server infers the Actor tool schema from this input. I verified the live tool rather than trusting the source file. On July 30, tools/list exposed pink_comic--us-business-entity-search with the exact routine fields, optional caApiKey, MCP waitSecs, their types, and the bounded defaults.
Failure three: an empty dataset is not a business conclusion
Public registries are fragmented. A source can return no matches, time out, omit fields, or expose only a basic record. A similar business name can also produce several plausible entities.
My Actor preserves valid rows when one state adapter fails. It logs the state failure and continues. That is better than failing a multi-state run, but the current Actor still returns an empty dataset when no state yields a row. It does not prove that the company does not exist.
I therefore give the calling agent an explicit interpretation rule:
Treat an empty result as “no match returned from the requested sources,” not as proof that the entity is absent, inactive, or dissolved.
On newer evidence Actors, I return explicit no_match and source_unavailable outcome records. I would add the same distinction here before using the tool for unattended decisions. For now, the tool is an evidence collector, not a KYB decision engine.
I apply the same boundary to status. Active is the label reported by that registry at retrieval time. It is not a certified good-standing result, identity verification, tax clearance, or approval to onboard the business.
This caveat is not defensive copy. It changes the agent's answer from “the company is active” to “the New York source returned an Active record; review the linked record and entity ID.”
Prerequisites
To reproduce the workflow, you need:
- An Apify account and either OAuth access or an API token.
- An MCP client for the configuration example.
- Python 3.10 or newer plus
requestsfor the direct smoke test. - A small Apify usage budget. The live Actor uses pay-per-event pricing: a
$0.0001start plus$0.002per dataset row.
Connecting the Apify Actor to an AI agent
The Apify MCP server supports the MCP Streamable HTTP transport. I scope the URL to one Actor so the client receives a small, relevant tool list:
{
"mcpServers": {
"apify-business-registry": {
"url": "https://mcp.apify.com?tools=pink_comic/us-business-entity-search"
}
}
}
On first connection, Apify can authenticate through OAuth. For clients that use a token directly, I set an authorization header instead of putting the token in the URL:
{
"mcpServers": {
"apify-business-registry": {
"url": "https://mcp.apify.com?tools=pink_comic/us-business-entity-search",
"headers": {
"Authorization": "Bearer <APIFY_TOKEN>"
}
}
}
}
I learned that rule the uncomfortable way. An older maintenance script put an Apify token in a query string. When a subprocess failed, its traceback printed the complete command. The token reached a log even though the HTTP request itself was encrypted. Headers reduce that exposure surface, and OAuth avoids handing the client a long-lived token at all.
The MCP endpoint is https://mcp.apify.com. Adding /mcp returns 404. The client must also accept both application/json and text/event-stream when implementing Streamable HTTP directly.
A complete MCP smoke test
Most users should let Claude, Cursor, Codex, or another MCP client handle the protocol. I still keep a direct smoke test because it catches schema and transport drift before an agent does.
This script initializes a session, lists tools, runs one bounded New York search, and fetches the resulting dataset. It uses requests and reads APIFY_TOKEN from the environment.
import json
import os
import requests
URL = (
"https://mcp.apify.com"
"?tools=pink_comic/us-business-entity-search"
)
HEADERS = {
"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
def parse_response(response):
response.raise_for_status()
events = []
for line in response.text.splitlines():
if line.startswith("data:"):
events.append(json.loads(line[5:].strip()))
return events[-1] if events else response.json()
def rpc(method, params=None, request_id=None):
payload = {"jsonrpc": "2.0", "method": method}
if params is not None:
payload["params"] = params
if request_id is not None:
payload["id"] = request_id
response = requests.post(URL, headers=HEADERS, json=payload, timeout=120)
if request_id is None:
response.raise_for_status()
return response, None
return response, parse_response(response)
initialize, _ = rpc(
"initialize",
{
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "registry-smoke-test", "version": "1.0"},
},
1,
)
HEADERS["mcp-session-id"] = initialize.headers["mcp-session-id"]
rpc("notifications/initialized")
_, tools = rpc("tools/list", {}, 2)
tool_names = [tool["name"] for tool in tools["result"]["tools"]]
assert "pink_comic--us-business-entity-search" in tool_names
_, run = rpc(
"tools/call",
{
"name": "pink_comic--us-business-entity-search",
"arguments": {
"searchQuery": "Goldman Sachs",
"states": ["NY"],
"maxResults": 1,
"fetchDetails": True,
"waitSecs": 45,
},
},
3,
)
run_data = run["result"]["structuredContent"]
assert run_data["status"] == "SUCCEEDED"
dataset_id = run_data["storages"]["datasets"]["default"]["id"]
_, dataset = rpc(
"tools/call",
{
"name": "get-dataset-items",
"arguments": {"datasetId": dataset_id, "limit": 1, "clean": True},
},
4,
)
print(json.dumps(dataset["result"]["structuredContent"]["items"], indent=2))
The direct test is intentionally small. It proves the Actor tool is discoverable, the live schema accepts the documented fields, the run completes, and the dataset is retrievable.
What the live AI agent call returned
I ran that flow against production on July 30, 2026. Run rtPs5k6zEqfZPe1Pp succeeded in 1.53 seconds and wrote one dataset item with 15 available fields.
The MCP dataset call returned this evidence:
{
"entityName": "GOLDMAN SACHS & CO. LLC",
"entityId": "1560743",
"state": "NY",
"entityType": "DOMESTIC LIMITED LIABILITY COMPANY",
"status": "Active",
"filingDate": "1991-07-10T00:00:00",
"jurisdiction": "New York, United States",
"registeredAgent": "C T CORPORATION SYSTEM",
"address": {
"street": "28 LIBERTY STREET",
"city": "NEW YORK",
"state": "NY",
"zip": "10005"
},
"officers": null,
"sourceUrl": "https://apps.dos.ny.gov/publicInquiry/",
"searchQuery": "Goldman Sachs"
}
The row is useful because the agent can cite the New York Department of State public inquiry, distinguish the matched jurisdiction, and ask a reviewer to compare the entity ID. It is deliberately not a final compliance verdict.
What I would change next
I would make three more changes before allowing an agent to act without review.
First, I would emit an explicit outcome object for every requested state. found, no_match, and source_unavailable must remain different states. Logs are not enough when the agent only sees the dataset.
Second, I would make arrays consistently serialize as empty arrays instead of null. The live New York row returned "officers": null. The key is stable, but [] is easier for strict consumers.
Third, I would add a total response cap alongside the per-state cap. The per-state rule is now honest, but an agent selecting 16 states can still multiply the output. A total cap provides a second cost boundary.
The broader lesson is simple: exposing an Actor through MCP is the easy part. The work is making omission, failure, cost, and evidence semantics obvious enough that an agent cannot invent them.
For me, the best test was not “did the Actor run?” It was “can an agent call it with missing context, get a bounded result, and describe exactly what the source did and did not establish?”
FAQ
Does the Apify MCP server require an API token?
The recommended hosted flow supports OAuth. A client can also send an Apify API token in the Authorization header. I avoid tokens in query strings because URLs are more likely to appear in logs and command history.
Why scope the MCP URL to one Actor?
A smaller tool list reduces selection ambiguity and prompt overhead. My registry workflow does not need thousands of unrelated Actors, so I expose only pink_comic/us-business-entity-search.
Does an Active registry record prove good standing?
No. It is point-in-time administrative evidence from the named source. It does not establish identity, certified good standing, tax compliance, licensing, solvency, or suitability.
Where can I inspect the implementation and tool?
The live Actor is on the Apify Store. Apify's MCP server documentation covers current client setup and authentication. The complete smoke test and captured output accompany this draft in the submission materials.
Top comments (0)