This article was originally published on the Zenrows blog.
Your research agent might be reading fake pages. When a target sits behind Cloudflare, a plain requests call returns a 403 and a "Just a moment..." challenge page in place of the content. Unless the tool checks for that, the agent can treat the challenge markup as the answer and report on a page it never saw.
In this tutorial, you'll see how that happens and how to fix it in a Google Agent Development Kit (ADK) agent. You'll give the agent a web tool that routes each fetch through Zenrows Fetch and returns the page as Markdown, then connect a second agent to the hosted Zenrows MCP server with no function code. You'll need Python 3.10 or later, a Google AI Studio API key, and a Zenrows API key. The free plan includes 5,000 credits a month. The complete code is on GitHub.
What You'll Build
- You'll register a function tool,
fetch_protected_page, on a Gemini-powered ADK agent. - You'll write a comparison script that fetches one URL with plain HTTP and with Zenrows.
- You'll add a second agent that loads the Zenrows MCP tools through ADK's
MCPToolset.
1. Set Up the Project
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
The pinned requirements.txt installs google-adk 2.9.2 with its MCP extra.
google-adk[mcp]==2.9.2
mcp==1.30.0
python-dotenv==1.2.3
requests==2.34.2
pytest==9.1.1
ruff==0.16.8
-e .
Copy the environment template and add your keys. The .gitignore excludes .env.
cp .env.example .env
GOOGLE_API_KEY=your_google_api_key
ZENROWS_API_KEY=your_zenrows_api_key
Here's the project layout.
google-adk-zenrows-research-agent/
├── scripts/
│ ├── compare_fetch.py
│ └── list_mcp_tools.py
├── tests/
│ ├── test_agent.py
│ ├── test_mcp_agent.py
│ └── test_web.py
├── zenrows_mcp_agent/
│ ├── __init__.py
│ └── agent.py
├── zenrows_research_agent/
│ ├── __init__.py
│ ├── agent.py
│ └── web.py
├── .env.example
├── .gitignore
├── LICENSE
├── pyproject.toml
├── README.md
└── requirements.txt
2. Write the Fetch Function
Create zenrows_research_agent/web.py.
"""Web retrieval tools used by the Google ADK agent."""
import os
import requests
ZENROWS_ENDPOINT = "https://api.zenrows.com/v1/"
DEFAULT_TIMEOUT = 180
MAX_TOOL_CONTENT = 12_000
def fetch_with_http(url: str) -> dict:
"""Fetch a public URL with a standard HTTP request for comparison."""
try:
# send the request without a protected-site access service.
response = requests.get(
url,
headers={"User-Agent": "google-adk-research-agent/0.1"},
timeout=20,
)
except requests.RequestException as exc:
return {"status": "error", "url": url, "error": str(exc)}
return {
"status": "success" if response.ok else "blocked",
"url": url,
"status_code": response.status_code,
"content": response.text[:2_000],
}
def fetch_protected_page(url: str) -> dict:
"""Fetch a protected or JavaScript-rendered page through Zenrows.
Args:
url: The complete public URL the research agent needs to read.
Returns:
A dictionary containing the request status, source URL, and page content.
"""
api_key = os.getenv("ZENROWS_API_KEY")
if not api_key:
return {
"status": "error",
"url": url,
"error": "ZENROWS_API_KEY is not configured.",
}
# let Adaptive Stealth Mode select the request configuration.
params = {
"apikey": api_key,
"url": url,
"mode": "auto",
"response_type": "markdown",
"original_status": "true",
}
try:
# give Adaptive Stealth Mode enough time to retrieve the page.
response = requests.get(
ZENROWS_ENDPOINT,
params=params,
timeout=DEFAULT_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
# remove the API key from the error text before it reaches the model.
error = str(exc).replace(api_key, "[REDACTED]")
return {"status": "error", "url": url, "error": error}
# do not return a successful result without page content.
if not response.text.strip():
return {
"status": "error",
"url": url,
"error": "Zenrows returned an empty response.",
}
return {
"status": "success",
"url": url,
"content": response.text[:MAX_TOOL_CONTENT],
}
fetch_protected_page sends the URL to Zenrows with mode=auto, which turns on Adaptive Stealth Mode. It starts with the lightest configuration likely to work and escalates only when the target needs it. A few safeguards keep the agent honest.
-
response_type=markdownreturns readable text in place of raw HTML. -
original_status=truepasses through the target's status code, so an error page from the target counts as an error. - The 180-second timeout gives
mode=autoroom to finish on protected targets. - Content is capped at 12,000 characters, and an empty response is reported as an error.
- The API key is redacted from any error text before it reaches the model.
fetch_with_httpis a plain request used only for the comparison. ## 3. Register the Function as an ADK Tool Createzenrows_research_agent/agent.py.
"""Google ADK agent configuration."""
from pathlib import Path
from dotenv import load_dotenv
from google.adk.agents import Agent
from .web import fetch_protected_page
# load the API keys stored in the project environment file.
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
# register the Zenrows retrieval function as an ADK tool.
root_agent = Agent(
model="gemini-3.6-flash",
name="web_research_agent",
description="Researches public web pages that ordinary HTTP tools cannot read.",
instruction=(
"You are a web research agent. Use fetch_protected_page whenever a task "
"requires content from a supplied URL. Base the answer only on returned "
"page content, mention the source URL, and state clearly when retrieval "
"fails or the page does not contain the requested information."
),
tools=[fetch_protected_page],
)
ADK builds the tool schema from the function's name, type hints, and docstring, so the model only has to supply a URL.
4. Compare Plain HTTP With Zenrows
Create scripts/compare_fetch.py.
"""Compare standard HTTP access with Zenrows access."""
import json
from dotenv import load_dotenv
from zenrows_research_agent.web import fetch_protected_page, fetch_with_http
TARGET_URL = "https://www.scrapingcourse.com/cloudflare-challenge"
def summarize(result: dict) -> dict:
"""Reduce a fetch result to fields that are safe to print."""
return {
"status": result["status"],
"url": result["url"],
"status_code": result.get("status_code"),
"content_preview": result.get("content", "")[:300],
"error": result.get("error"),
}
if __name__ == "__main__":
# load the Zenrows API key from the project environment.
load_dotenv()
# retrieve the same URL through both access methods.
comparison = {
"standard_http": summarize(fetch_with_http(TARGET_URL)),
"zenrows": summarize(fetch_protected_page(TARGET_URL)),
}
# print the comparison without exposing the API key.
print(json.dumps(comparison, indent=2))
python scripts/compare_fetch.py
{
"standard_http": {
"status": "blocked",
"url": "https://www.scrapingcourse.com/cloudflare-challenge",
"status_code": 403,
"content_preview": "<!DOCTYPE html><html lang=\"en-US\"><head><title>Just a moment...</title><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"><meta http-equiv=\"X-UA-Compatible\" content=\"IE=Edge\"><meta name=\"robots\" content=\"noindex,nofollow\"><meta name=\"viewport\" content=\"width=device-width,initial-scal",
"error": null
},
"zenrows": {
"status": "success",
"url": "https://www.scrapingcourse.com/cloudflare-challenge",
"status_code": null,
"content_preview": "[ Scraping Course](http://www.scrapingcourse.com/)\n\n# Cloudflare Challenge\n\n\n\n## You bypassed the Cloudflare challenge! :D",
"error": null
}
}
The plain request got a 403 and the challenge page. Zenrows returned the page heading and the verification message. The Zenrows status_code reads null because the function doesn't return it.
5. Run the Agent
adk run zenrows_research_agent
Submit this task.
Fetch https://www.scrapingcourse.com/cloudflare-challenge and report the page title, whether the challenge was passed, and the visible verification text. Cite the source URL.
[web_research_agent]: Based on the content retrieved from [https://www.scrapingcourse.com/cloudflare-challenge](https://www.scrapingcourse.com/cloudflare-challenge):
* **Page Title / Main Heading:** Cloudflare Challenge
* **Challenge Status:** Passed / Bypassed
* **Visible Verification Text:** "You bypassed the Cloudflare challenge! :D"
* **Source URL:** `https://www.scrapingcourse.com/cloudflare-challenge`
Run adk web if you'd prefer to test in ADK's browser UI.
6. Connect the Zenrows MCP Server
Create zenrows_mcp_agent/agent.py.
"""Google ADK agent that uses the hosted Zenrows MCP server."""
import os
from pathlib import Path
from dotenv import load_dotenv
from google.adk.agents import Agent
from google.adk.tools.mcp_tool import MCPToolset, StreamableHTTPConnectionParams
# load the API keys stored in the project environment file.
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
# connect to the hosted Zenrows MCP server with the API key as a bearer token.
zenrows_toolset = MCPToolset(
connection_params=StreamableHTTPConnectionParams(
url="https://mcp.zenrows.com/mcp",
headers={"Authorization": f"Bearer {os.getenv('ZENROWS_API_KEY', '')}"},
)
)
# the agent chooses between the Zenrows scrape and browser tools on its own.
root_agent = Agent(
model="gemini-3.6-flash",
name="web_research_mcp_agent",
description="Researches public web pages through the Zenrows MCP server.",
instruction=(
"You are a web research agent. Use the Zenrows tools whenever a task "
"requires content from a supplied URL. Base the answer only on returned "
"page content, mention the source URL, and state clearly when retrieval "
"fails or the page does not contain the requested information."
),
tools=[zenrows_toolset],
)
MCPToolset connects to https://mcp.zenrows.com/mcp with your API key as a bearer token, and ADK loads the server's tools. Check which ones arrived with scripts/list_mcp_tools.py.
"""Check which Zenrows MCP tools Google ADK loads for the MCP agent."""
import asyncio
from zenrows_mcp_agent.agent import zenrows_toolset
RESEARCH_TOOLS = ["scrape", "extract", "crawl_create", "batch_create", "browser_navigate"]
async def main() -> None:
# ask the hosted Zenrows MCP server which tools it exposes.
tools = await zenrows_toolset.get_tools()
await zenrows_toolset.close()
names = {tool.name for tool in tools}
# confirm the tools a research agent relies on are loaded.
print(f"Loaded {len(names)} Zenrows tools")
for name in RESEARCH_TOOLS:
print(f"{name}: {'available' if name in names else 'not loaded'}")
if __name__ == "__main__":
asyncio.run(main())
python scripts/list_mcp_tools.py
adk run zenrows_mcp_agent
Loaded 62 Zenrows tools
scrape: available
extract: available
crawl_create: available
batch_create: available
browser_navigate: available
ADK prints experimental-feature warnings before that output.
Function Tool or MCP Server
| Function tool | Zenrows MCP | |
|---|---|---|
| What the agent calls |
fetch_protected_page, one typed function |
scrape, extract, Crawl, Batch, and browser tools |
| Request settings | Fixed in code (mode=auto, Markdown) |
Chosen by the MCP server per task |
| Code you maintain | The function and its tests | One MCPToolset block |
| Pick it when | You want one fixed, typed request | You want the agent to choose among scraping, crawling, and browser actions |
Wrapping Up
Your ADK agent now reads protected pages as Markdown through a typed function, and a second agent reaches the full Zenrows toolset over MCP. Change the URL and the prompt to point it at your own research tasks.
What's Next
- Use Extract with
extract=autowhen you need structured JSON fields. - Discover linked pages with Crawl or run large URL lists as one job with Batch.
- Build the same idea with Agno and Zenrows or as a multi-agent system with AG2.

Top comments (0)