Why I needed Actors as agent tools
I am building DogeVault Stylist, a Next.js app that recommends dog-fashion products. A user uploads an image of their dog, the app suggests clothes, harnesses, coats, shoes, hats, and links to live listings, as part of International Dog Day hackathon. The hard part was never the styling logic. It was getting real, current product data without hand-copying URLs from Amazon and eBay.
I tried a few things first. A generic search API key worked but returned thin results and cost per query even when I got nothing useful. Scraping the sites myself with Playwright was fragile — every layout change broke my selectors.
Then I found the Apify MCP server. It exposes thousands of Apify Actors as tools over the Model Context Protocol. An AI agent can discover a scraper, run it, and read the dataset — no manual trigger. To be clear, this is the server that exposes Actors to AI clients, which is different from MCP connectors, where an Actor reaches out to a third-party service. That fit my stack perfectly, because I already drive development with opencode, a terminal coding agent, and I wanted to build a small Python agent with Google's Agent Development Kit (ADK).
This article shows exactly how I configured the Apify MCP server in opencode, how I call the same server from my own TypeScript code, and how I built an ADK agent that shops for poodle harnesses on its own. All the code is from my real project and has been tested.
What you need before starting
Before you follow along, gather these:
-
An Apify account and an API token from Settings → API & Integrations. You can also use OAuth instead of a token, which I do for
opencode. - opencode installed on your machine.
- Python 3.10 or newer for the ADK sample.
- A Google AI Studio API key for Gemini, since the ADK agent uses Gemini as its model.
- The Apify E-commerce Scraping Tool Actor. It is the Actor I expose to the agent for product search.
Note: For sample code that I showed in the sections below, it is only a snippet. Look at the repo if you plan to run it, especially on the ADK sample.
How the Apify MCP server turns Actors into agent tools
The Apify MCP server lives at [https://mcp.apify.com](https://mcp.apify.com). It speaks Streamable HTTP, the current MCP transport. You point any MCP-compatible client at that URL and the server hands back a list of tools.
Some tools are generic: search-actors, fetch-actor-details, get-actor-run, and get-dataset-items. Others are Actor-specific. The E-commerce Scraping Tool shows up as a tool named apify--e-commerce-scraping-tool. From the agent's point of view, calling that tool is just like calling a function — it does not know or care that an Actor runs in the background.
One detail shaped everything I built: Actor runs are asynchronous. When the agent calls the e-commerce tool, the server starts a run and returns a runId plus a datasetId. The run might still be RUNNING. To get results, the agent polls get-actor-run until the status is SUCCEEDED, then calls get-dataset-items to read the rows. I kept that loop in mind for my own agent code.
You can shrink the tool list with a tools query parameter, for example ?tools=fetch-actor-details,apify/e-commerce-scraping-tool. Fewer tools means a smaller schema and fewer tokens in the model's context. I used that trick in both setups.
Configuring the Apify MCP server in opencode
Opencode reads its config from ~/.config/opencode/opencode.jsonc. I added an apify entry under mcp:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"apify": {
"type": "remote",
"url": "https://mcp.apify.com/?tools=fetch-actor-details,apify/e-commerce-scraping-tool",
"oauth": {}
}
}
}
The empty "oauth": {} block tells opencode to use the OAuth flow instead of a bearer token, so my long-lived API token never lands in a config file.
Authenticating with opencode
After saving the config, I ran:
opencode mcp auth apify
opencode printed an authorization URL and opened my browser.
The command prints the authorization URL and waits for the browser round-trip.
The browser first asked me to choose an account.
The account selector decides which Apify account the integration attaches to.
Then Apify showed the scopes opencode requested — profile data and full API access — and an Allow access button.
Profile data plus full API access — and the unverified-app banner that is normal for dynamic client registration.
One thing on that screen made me pause: a banner warning that the app "was registered dynamically, and wasn't verified by Apify." The warning is expected for dynamically registered OAuth clients like opencode — I checked that the redirect URL pointed at 127.0.0.1 before clicking Allow access.
I approved, and the terminal reported success.
Back in the terminal: authentication successful.
Finally, opencode's MCP panel listed the apify server as connected and enabled when I use the /mcps command.
The MCP panel lists the apify server as connected and enabled.
Asking opencode to shop for me
With the server connected, I asked opencode a plain-English question: Find me ecommerce product poodle dog harness. opencode picked the apify--e-commerce-scraping-tool tool, called it, polled the run, and read the dataset. It returned ten real eBay listings, prices between $9.89 and $111.00, each with a URL.
The Apify MCP successfully returns products to opencode
That was the moment I stopped worrying about product data and started wiring the same server into my app.
Calling Apify MCP from my own TypeScript code
The opencode flow is great for development. But my Next.js app also needs product data at request time, server-side, without a human in the loop. So I wrote a small client in apps/web/src/lib/apifyMcp.ts.
I used the official MCP SDK for TypeScript. The transport is StreamableHTTPClientTransport, pointed at [https://mcp.apify.com/?tools=apify/e-commerce-scraping-tool,get-actor-run,get-dataset-items](https://mcp.apify.com/?tools=apify/e-commerce-scraping-tool,get-actor-run,get-dataset-items) — the same ?tools= filter as before, this time including the two storage tools my polling loop needs — with my API token in an Authorization: Bearer header. A token is appropriate here because this is a server, not a human session.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { getServerEnv } from "./env";
import type { Product } from "./types";
export async function searchProductsWithApify(
queries: string[],
): Promise<{ products: Product[]; status: "ok" | "missing_key" | "error"; note?: string }> {
const env = getServerEnv();
if (!env.apifyToken) {
return { products: [], status: "missing_key", note: "APIFY_TOKEN missing." };
}
const client = new Client({ name: "dogevault-stylist", version: "0.1.0" }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(env.apifyMcpUrl), {
requestInit: {
headers: { Authorization: `Bearer ${env.apifyToken}` },
},
});
try {
await client.connect(transport);
const listed = await client.listTools();
// Deterministic only because the ?tools= filter narrows the list to these three tools.
const ecommerce = listed.tools.find((t) => /e-commerce|ecommerce|apify/i.test(t.name));
const runTool = listed.tools.find((t) => t.name === "get-actor-run");
const itemsTool = listed.tools.find((t) => t.name === "get-dataset-items");
if (!ecommerce || !runTool || !itemsTool) {
throw new Error("Required Apify MCP tools not found");
}
// Kick off a run, then poll until SUCCEEDED, then read the dataset.
const started = await client.callTool({
name: ecommerce.name,
arguments: {
searchEngineKeyword: queries[0] ?? "luxury dog collar",
countryCode: "us",
scrapeMode: "AUTO",
maxProductResults: 10,
maxSearchEngineProducts: 10,
maxSearchEngineResults: 10,
},
});
const run = JSON.parse(started.content[0].text);
let status = run.status ?? "RUNNING";
let datasetId = run.storages?.datasets?.default?.id;
for (let i = 0; i < 4 && status === "RUNNING"; i++) {
const polled = await client.callTool({ name: runTool.name, arguments: { runId: run.runId, waitSecs: 30 } });
const r = JSON.parse(polled.content[0].text);
status = r.status ?? status;
datasetId ??= r.storages?.datasets?.default?.id;
}
if (status !== "SUCCEEDED") throw new Error(`Run ended with status ${status}`);
const itemsRes = await client.callTool({
name: itemsTool.name,
arguments: { datasetId, limit: 10, clean: true },
});
const items = JSON.parse(itemsRes.content[0].text);
const products = normalizeProducts(items.items ?? items, queries[0], 10);
return { products, status: "ok" };
} catch (error) {
return { products: [], status: "error", note: error instanceof Error ? error.message : "Unknown error" };
} finally {
await transport.close().catch(() => undefined);
}
}
The shape of the run object comes straight from the MCP server: runId, status, and storages.datasets.default.id. I unwrap the content[0].text payload because the MCP SDK wraps tool results in content blocks. My normalizeProducts helper then flattens the various field names Apify Actors use (name vs title, offers.price vs price) into one Product type. The snippet leans on two small helpers from my project (getServerEnv, normalizeProducts); the complete file ships in the project repo, and I tested it against @modelcontextprotocol/sdk 1.30.
It is the same run/poll/dataset loop the opencode session executed a few sections up, but with deterministic code instead of a model deciding when to poll. One constraint to know before you copy it: the loop waits at most four polls of 30 seconds, so a run that needs more than about two minutes throws. I run the app with next start — a long-lived Node process — so that budget is fine; on a serverless host you would move the polling out of the request path.
One thing surprised me: the server returns a nextStep hint in the run payload, literally telling the client which tool to call next. I did not use it, but it is a nice safety net if your agent ever gets confused about polling.
Building a Google ADK agent that calls Apify as an MCP tool
opencode proves the server works with a coding agent. I also wanted a standalone agent I could ship — one that answers product questions on its own, not through a coding assistant. Google's ADK fits that job, and it has first-class MCP support through its MCPToolset class. The setup is documented in the ADK MCP tools guide.
The install gotcha that cost me an hour
I started the obvious way:
python3 -m venv .venv
source .venv/bin/activate
pip install google-adk
Then my agent failed with:
ImportError: cannot import name 'McpToolset' from 'google.adk.tools.mcp_tool'
The mcp_tool package wraps its imports in a try/except and silently logs MCP Tool is not installed. The core google-adk install does not include the mcp Python package. You need the extra:
pip install "google-adk[mcp]"
After that, the import worked. I am calling this out because the silent failure is easy to miss and the fix is a one-word change. Everything in this section was tested against google-adk 2.7.0, which pulled the mcp package at 1.29.0 into my venv.
The agent code
My agent lives in adk-apify-sample/apify_agent/agent.py. It creates an MCPToolset with StreamableHTTPConnectionParams pointing at the Apify server, with the same Bearer header I use in TypeScript.
import os
from google.adk.agents import LlmAgent
from google.adk.tools.mcp_tool import McpToolset
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
APIFY_TOKEN = os.getenv("APIFY_TOKEN")
APIFY_MCP_URL = os.getenv(
"APIFY_MCP_URL",
"https://mcp.apify.com/?tools=apify/e-commerce-scraping-tool,get-actor-run,get-dataset-items",
)
if not APIFY_TOKEN:
raise RuntimeError("APIFY_TOKEN is not set. Export it or add it to .env")
apify_toolset = McpToolset(
connection_params=StreamableHTTPConnectionParams(
url=APIFY_MCP_URL,
headers={
"Authorization": f"Bearer {APIFY_TOKEN}",
"Content-Type": "application/json",
},
)
)
root_agent = LlmAgent(
model="gemini-2.5-flash",
name="product_scout_agent",
description="Finds live e-commerce product data by calling Apify Actors as MCP tools.",
instruction=(
"You are a product research agent. Use the Apify e-commerce scraping tool to "
"find real product offers for the user's query. Actor runs are asynchronous: "
"if a tool returns a run that is still RUNNING, call get-actor-run with the "
"runId to wait for it, then call get-dataset-items with the datasetId to read "
"the products. Summarize at most 5 products with name, price, currency and URL."
),
tools=[apify_toolset],
)
I hold a module-level reference to apify_toolset so I can close it after the run. The instruction tells the model about the async run/poll/dataset loop, which matters because ADK does not know Apify's convention by default.
I drive the agent with a small runner script, run_sample.py:
import asyncio
import os
from dotenv import load_dotenv
load_dotenv()
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from apify_agent.agent import apify_toolset, root_agent
APP_NAME = "apify_product_scout"
USER_ID = "article-author"
async def main() -> None:
prompt = os.getenv("PROMPT", "Find me ecommerce products for a poodle dog harness")
session_service = InMemorySessionService()
session = await session_service.create_session(app_name=APP_NAME, user_id=USER_ID)
runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)
message = types.Content(role="user", parts=[types.Part(text=prompt)])
try:
async for event in runner.run_async(
user_id=USER_ID, session_id=session.id, new_message=message
):
if not event.content or not event.content.parts:
continue
for part in event.content.parts:
if getattr(part, "text", None):
print(part.text)
elif getattr(part, "function_call", None):
print(f"[tool call] {part.function_call.name}")
finally:
await apify_toolset.close()
if __name__ == "__main__":
asyncio.run(main())
I run it from the project directory:
.venv/bin/python run_sample.py
Running it and watching it recover from its own mistake
My favorite part of this build was watching the agent fail, then fix itself. On the first run it produced this:
MCP tool execution failed with McpError: MCP error -32602: Invalid arguments
for tool "apify--e-commerce-scraping-tool".
Validation errors: /marketplaces/2: must be equal to one of the allowed values.
[tool call] apify--e-commerce-scraping-tool
[tool call] apify--e-commerce-scraping-tool
[tool call] apify--e-commerce-scraping-tool
[tool call] get-dataset-items
Here are 5 e-commerce products for a poodle dog harness:
* **Cute Bow Small Dog Cat Harness and Leash Pet Puppy Warm Fleece Vest Coat NH1**
- Price: 13
- URL: https://www.ebay.com/itm/227123269667
* **JSXD Dog Harness, No-Pull Service Dog Harness with Handle**
- Price: 16.19
- URL: https://www.amazon.com/dp/B082VSGW7R
...
The model guessed [www.walmart.com](https://www.walmart.com) as a marketplace, but that string is not in the Actor's enum. The MCP server rejected the call with a precise validation error that named the bad field. Gemini read the error, dropped Walmart from the list, retried with Amazon and eBay, and succeeded on the third attempt.
That recovery is the real payoff of exposing Actors as MCP tools. The Actor's input schema becomes the agent's contract. The agent does not need me to babysit invalid inputs; it gets structured feedback and corrects course on its own.
Not every hit was a winner either — the fleece "Dog Cat" vest in that list is not what I would show a poodle owner. That is exactly why the instruction says to summarize, not dump: the model keeps the plausible rows and drops the rest.
What I would do differently next time
Three things, in order of how much they cost me:
-
Install the MCP extra up front. I lost an hour to the silent
ImportError. Addgoogle-adk[mcp]to your bootstrap script. -
Restrict the tool list in every environment. My first ADK run loaded every tool, and the function declarations bloated the prompt. The
?tools=query parameter fixed it. Use it everywhere, even in development. -
Teach the agent the run/poll/dataset loop in its instruction. ADK's
MCPToolsetis transparent about calls, but the model still needs to know that Apify runs are async. One sentence in the instruction prevented a lot of confused tool-spaghetti.
I would also move the Apify token out of .env and into a secret manager for any real deployment. Bearer tokens are fine for servers, but they should never sit in a repo.
And I would keep leaning on Apify's managed Actors rather than raw scrapers for anything customer-facing. My own Playwright attempt got blocked within an hour, and the managed Actors handle rate limiting and site-specific access rules for me — which keeps the project on the right side of each site's terms instead of me guessing at them.
Conclusion
The Apify MCP server turned a problem I had been stuck on — getting live product data without maintaining scrapers — into a single tool call my agents can make on their own. The lesson that stuck with me is that the Actor's input schema becomes the agent's contract: once the scraper was exposed as a tool, even a bad marketplace guess came back as a structured validation error the model could read and recover from, instead of silent garbage in my dataset. Three very different clients — a terminal coding assistant, a server-side TypeScript function, and a standalone Python agent — all got there through the same URL and the same run/poll/dataset loop.
If you have an Actor that does something useful for an agent, expose it through the MCP server and try it from your own agent first. The schema validation alone is worth the setup.
FAQ
-
Do I need an Apify API token to use the MCP server?
Only for running Actors and reading storage. For discovery and docs tools, you can connect anonymously. For everything else, you can use OAuth (for human-facing clients like
opencode) or a bearer token (for servers). - Does the ADK agent only work with Gemini? No. ADK supports many models, including Claude, OpenAI, and local models via Ollama. I used Gemini because I already had a Google AI Studio key.
- Can I use Server-Sent Events instead of Streamable HTTP? Apify is deprecating SSE transport. Streamable HTTP is the recommended path and the one I used in both setups.
Top comments (0)