DEV Community

Benny for Zenrows

Posted on Originally published at zenrows.com

We Gave a Local LLM Live Web Access. Here’s How

This article was originally published on the Zenrows blog.

We gave a local LLM live web access, and this post shows how. You'll connect a qwen3 model in Ollama to Zenrows MCP, first with no code through the ollmcp bridge and then with a short Python agent that is its own MCP client. You'll need Ollama, a tool-capable model, Node.js, and Python 3.11 or later. A Zenrows API key is optional.

Ollama publishes web_search and web_fetch as hosted APIs that you call with an Ollama API key, and it has no MCP client of its own. In our test runs, the local qwen3 agent read a Cloudflare-fronted Indeed search and cleared an anti-bot challenge page through Zenrows MCP. A plain requests.get() to the same challenge page returned a 403. The code is on GitHub.

How It Works

The question goes to qwen3 on Ollama, the model picks the extract tool, the Zenrows MCP server fetches the page, and only the parsed data goes back to the model

You ask a question, Ollama picks a tool, Zenrows makes the requests, and the target site returns the pages. The model never contacts the site. The agent calls the extract tool, Zenrows fetches the page through Fetch with protected access, JavaScript rendering, and proxy rotation in one API call, and the model gets the page as structured JSON fields.

Zenrows MCP has two transports. The remote server runs over Streamable HTTP and authenticates with your API key as a Bearer token. The local server runs as a STDIO subprocess through the @zenrows/mcp npm package with your key in an environment variable. This tutorial uses the local one, since it runs next to Ollama on your machine.

These tools matter for this setup.

  • scrape returns a page as Markdown by default, with HTML, PDF, and screenshot output also available.
  • extract returns structured JSON, which matters for local models with small context windows.
  • browser_* tools open a cloud browser session for tasks that span more than one request.
  • crawl_* tools collect the URLs behind one start page.
  • batch_* tools submit a list of URLs as one background job.

1. Install Ollama and a Tool-Capable Model

brew install ollama
brew services start ollama
Enter fullscreen mode Exit fullscreen mode

 

ollama pull qwen3
ollama show qwen3:latest
Enter fullscreen mode Exit fullscreen mode

Ollama registers the model as qwen3:latest. The ollama show output lists tools under Capabilities, which confirms the model can call the Zenrows tools.

  Model
    architecture        qwen3
    parameters          8.2B
    context length      40960
    embedding length    4096
    quantization        Q4_K_M

  Capabilities
    completion
    tools
    thinking
Enter fullscreen mode Exit fullscreen mode

2. Set Up the Project

python3 -m venv .venv
source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

 

pip install python-dotenv requests
Enter fullscreen mode Exit fullscreen mode

Without a key, the local server creates a Free account with 5,000 credits a month on its first tool call. To use your own account, create one and save the key in .env.

ZENROWS_API_KEY=your_key_here
Enter fullscreen mode Exit fullscreen mode

3. Install the ollmcp Bridge

ollmcp is an open-source Python bridge that connects MCP servers to a local Ollama model over STDIO, SSE, and Streamable HTTP. It also installs the ollama and mcp packages the agent imports later. This tutorial was tested with ollmcp 0.35.0 on Python 3.14.

pip install ollmcp
Enter fullscreen mode Exit fullscreen mode

4. Configure the Server and Chat With No Code

Create zenrows-mcp.json.

{
  "mcpServers": {
    "zenrows": {
      "command": "sh",
      "args": ["-c", "set -a && . ./.env && set +a && exec npx -y @zenrows/mcp"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The sh -c command sources .env into the environment before starting npx, so the JSON holds no secret and can be committed. Add .env to .gitignore, and run ollmcp from this directory because the .env path is relative.

ollmcp --servers-json zenrows-mcp.json --model qwen3:latest
Enter fullscreen mode Exit fullscreen mode

On October 21, 2026, the bridge connected and listed the Zenrows tools (output trimmed).

Found server in config: zenrows
Connecting to server: zenrows
Successfully connected to zenrows with 62 tool(s) and 3 prompt(s) and 1 resource(s)/template(s)
╭──────────────────────────── 🔧 Available Tools ────────────────────────────╮
│ ✓ zenrows.scrape                         ✓ zenrows.extract                 │
│ ✓ zenrows.batch_create                   ✓ zenrows.batch_status            │
...
│ ✓ zenrows.browser_switch_tab             ✓ zenrows.browser_batch           │
╰─────────────────────────── 62/62 tools enabled ────────────────────────────╯
╭───────────────────────────────────────────────╮
│ Current model: qwen3:latest (ollama)          │
│ Capabilities: 🔧 Tools │ 💭 Thinking (medium) │
╰───────────────────────────────────────────────╯
Enter fullscreen mode Exit fullscreen mode

Every enabled tool's schema goes into the model's context, so type /tools and keep scrape and extract switched on for a small local model. /human-in-the-loop asks you to approve each tool call before it runs.

5. Build the Agent in Python

The script starts the Zenrows MCP server with npx, so it doesn't need ollmcp or zenrows-mcp.json to run. Create agent.py. The pinned versions are in the repo's requirements.txt.

from dotenv import load_dotenv
load_dotenv()

import asyncio
import json
import os
import ollama
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

SERVER = StdioServerParameters(
    command="npx",
    args=["-y", "@zenrows/mcp"],
    env=dict(os.environ),
)
MODEL = "qwen3:latest"
# give the model enough context to read the whole tool result
OPTIONS = {"num_ctx": 16384}
QUESTION = "Extract the page heading from https://www.scrapingcourse.com/antibot-challenge."
ALLOWED_ARGS = {"url"}

async def main():
    async with stdio_client(SERVER) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            listed = await session.list_tools()
            tools = [
                {"type": "function", "function": {
                    "name": t.name,
                    "description": t.description,
                    "parameters": t.input_schema,
                }}
                for t in listed.tools if t.name == "extract"
            ]

            messages = [{"role": "user", "content": QUESTION}]
            reply = ollama.chat(model=MODEL, messages=messages, tools=tools, options=OPTIONS)
            messages.append(reply["message"])

            for call in reply["message"].get("tool_calls") or []:
                args = {k: v for k, v in call.function.arguments.items() if k in ALLOWED_ARGS}
                print("calling", call.function.name, args)
                result = await session.call_tool(call.function.name, args)
                text = result.content[0].text
                print("result:", text[:300])
                # pass the model only the parsed fields from the response
                data = json.loads(text).get("data")
                messages.append({"role": "tool", "content": json.dumps(data)})

            print(ollama.chat(model=MODEL, messages=messages, tools=tools, options=OPTIONS)["message"]["content"])

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Three details matter on a local model.

  • OPTIONS sets the context window. Ollama uses 4,096 tokens by default, and the Indeed prompt later came to about 6,900 tokens. Without num_ctx, Ollama truncated that prompt and the model answered from part of the listings.
  • ALLOWED_ARGS filters the model's arguments and blocks anything the model invents, so only the URL reaches the server. Zenrows then turns on JavaScript rendering and premium proxies only when the page needs them.
  • The extract response wraps the parsed fields in a data object next to a few status flags. The script hands the model only that data object, which keeps the tool message small.

The test target is the Scrapingcourse antibot challenge page, which reports its own verdict.

python3 agent.py
Enter fullscreen mode Exit fullscreen mode

 

Using existing API key from env (secrets dir: /Users/user/.zenrows)
Zenrows MCP server running on stdio (secrets dir: /Users/user/.zenrows)
calling extract {'url': 'https://www.scrapingcourse.com/antibot-challenge'}
result: {"ok":true,"mode":"auto","empty":false,"data":{"challenge_result":{"message":"You bypassed the Antibot challenge! :D","status":"bypassed"},"page":{"challenge_type":"antibot","title":"Antibot Challenge"}}}
The page heading (h1) from the URL is:

**Antibot Challenge**

This is the title extracted from the webpage, which also happens to be the name of the antibot challenge page.
Enter fullscreen mode Exit fullscreen mode

For comparison, without_zenrows.py sends a plain request to the same page.

import requests

r = requests.get("https://www.scrapingcourse.com/antibot-challenge")
print(r.status_code, len(r.text))
Enter fullscreen mode Exit fullscreen mode

 

python3 without_zenrows.py
Enter fullscreen mode Exit fullscreen mode

 

403 5517
Enter fullscreen mode Exit fullscreen mode

The request was blocked with a 403.

A plain request gets a 403 challenge page, while the extract tool through Zenrows MCP returns the page's fields as JSON

6. Read a Live Cloudflare-Protected Site

Confirm what sits in front of Indeed.

curl -sI https://www.indeed.com | grep -iE "^(server|date):"
Enter fullscreen mode Exit fullscreen mode

 

date: Wed, 21 Oct 2026 08:15:02 GMT
server: cloudflare
Enter fullscreen mode Exit fullscreen mode

Create cloudflare_site.py with the same code as agent.py and change the QUESTION line.

QUESTION = "List the company names and job titles from https://www.indeed.com/jobs?q=data+analyst&l=Austin%2C+TX."
Enter fullscreen mode Exit fullscreen mode

 

python3 cloudflare_site.py
Enter fullscreen mode Exit fullscreen mode

 

Using existing API key from env (secrets dir: /Users/user/.zenrows)
Zenrows MCP server running on stdio (secrets dir: /Users/user/.zenrows)
calling extract {'url': 'https://www.indeed.com/jobs?q=data+analyst&l=Austin%2C+TX'}
result: {"ok":true,"mode":"auto","empty":false,"data":{"job_listings":[{"benefits":null,"company_logo_url":null,"company_name":"Paywall Bypass","detail_url":"/rc/clk?jk=1d9d5ad6081ee5e0&bb=0Jmy1L_Rym9Qr_CYZLvqO-iSA0EMSX-Z6TVgVMeYzV9VdW2jJoxc32S2GChQtdu5Qm9Zo3b5B6zKmWui0wMrKaTa7wukcUUmE83DPPtvU-K-2B_k_liBF0N
Here are the company names and job titles from the Indeed search results in Austin, TX:

1. **Paywall Bypass** - Data Analyst  
2. **Visa U.S.A. Inc.** - Data & Insights Analyst  
3. **Texas Department of Transportation** - Data Analyst IV  
4. **Austin Community College** - Analyst, Institutional Research (Hourly)  
5. **Austin Community College** - Analyst, Human Resources Data  
6. **University of Texas at Austin** - Data Analyst, College of Liberal Arts  
7. **Steck Systems** - Data Analyst  
8. **Apple** - Business Insights Analyst, Apple Store Online Operations  
9. **Google** - Business Data Analyst II, Google Global Infrastructure, Strategy and Operations  
10. **Google** - Business Data Analyst II, Google Global Infrastructure, Strategy and Operations  
11. **University of Texas at Austin** - Senior Data Analyst  
12. **The University of St. Augustine for Health...** - Institutional Research Analyst  
13. **Ericsson** - Data Analyst Strategy and Operations  
14. **Texas Department of Transportation** - Business Analyst II  
15. **SHI International Corporation** - Associate Analytics Engineer - Data & Visualization  
16. **Health & Human Services Comm** - Data Analyst V  

Let me know if you'd like additional details like locations or salary information!
Enter fullscreen mode Exit fullscreen mode

The extraction returned ok: true with 16 job listings and no challenge response, and the model listed every company and job title on the page.

Choosing an MCP Server

Here is how three MCP servers compared, as documented on October 21, 2026.

Zenrows MCP Firecrawl MCP Playwright MCP
How it runs Cloud or local Cloud or local On your browser
Auth OAuth sign-in or API key as a Bearer token, or ZENROWS_API_KEY environment variable OAuth sign-in or API key as a Bearer token, or FIRECRAWL_API_KEY environment variable None and runs locally
Protected access One integration combines fast scraping and full browser automation, with anti-bot bypass on every tool Their MCP documentation doesn't mention anti-bot handling or proxy rotation Runs from your own IP with no proxy rotation
Transport Hosted Streamable HTTP, or local STDIO via the command in the Zenrows MCP documentation Hosted Streamable HTTP at mcp.firecrawl.dev, or local via npx -y firecrawl-mcp Local STDIO via npx @playwright/mcp

Wrapping Up

One MCP configuration gave a local Ollama agent access to live pages, including Cloudflare-protected ones, while Zenrows handled access, rendering, and proxies for every request. For a task spanning several pages, ask in plain English and the agent can use the crawl and batch tools.

What's Next

Top comments (1)

Collapse
 
sikamikanikobg profile image
Arsen Apostolov •

the 62-tool surface is the number that quietly decides whether this works at all. every enabled tool's schema lands in the model's context, and a small local model has maybe 16k of it, so the moment you enable the whole set you're paying for 60 tools you'll never call. the /tools toggle and the ALLOWED_ARGS filter are the two moves that make it fit, and i'd argue the second one is underrated — it's not just a guardrail, it's what keeps the tool message small enough to feed back.

the num_ctx truncation trap is the one i keep hitting on my own box. ollama defaults to 4k and a single tool result can blow past that, so the model answers from half the page and it looks like a bad model when it's actually a silent cutoff. i built a read-only mcp server over my homelab (github.com/SikamikanikoBG/homelab-monitor) and the same lesson applied: cap the context, then cap the payload, in that order.

nice writeup — did you see the extract results degrade at all when the page got long, or does the structured JSON mostly dodge it?