Web Research MCP Server
Integrates with an existing Ollama service for structured data extraction using JSON Schema validation and for optional LLM-based selector resolution during browser automation.
Integrates with an existing SearXNG endpoint for web search, routing search requests through Firecrawl to the self-hosted SearXNG instance.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Web Research MCP Serversearch the web for the latest news on solid-state batteries"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Web Research
A self-hosted web research platform that exposes one stable set of REST and MCP interfaces for search, site discovery, scraping, crawling, structured extraction, and stateful browser interaction.
The project combines specialized services rather than forcing one component to do everything:
Capability Implementation
Search Self-hosted Firecrawl using an existing SearXNG endpoint
Map Self-hosted Firecrawl
Scrape Self-hosted Firecrawl
Crawl Self-hosted Firecrawl
Extract Dedicated FastAPI service using an existing Ollama endpoint + JSON Schema validation
Interact Dedicated Playwright browser-automation service, with optional Ollama selector resolution
REST Unified FastAPI gateway on port
8080
MCP FastMCP server exposing the same
capabilities on port 8081
Test UI Served by the gateway at /ui for
manually exercising the platform
MCP Console UI Served by the gateway at
/ui/mcp for running MCP tools and
building / executing workflows
Important: this Compose project does not start Ollama or SearXNG. It is designed to reuse Ollama and SearXNG instances that are already running separately.
Contents
Related MCP server: web-scraper-server
Why I built this
Self-hosted Firecrawl provides strong search, map, scrape, and crawl primitives. I wanted to add two capabilities to my self-hosted research environment without making application code depend directly on multiple backends:
Extract --- structured extraction through an existing Ollama service, validated against a caller-supplied JSON Schema with bounded retry.
Interact --- stateful browser automation through Playwright for navigation, clicking, typing, selecting, scrolling, reading page text, and screenshots.
A unified gateway then exposes the complete capability set as REST. A FastMCP server exposes the same backend capabilities to MCP clients and agents. The result is a stable Web Research contract — application code does not need to know which backend implements each capability.
Architecture
%%{init: {'theme': 'default', 'flowchart': {'useMaxWidth': true, 'htmlLabels': true}}}%%
flowchart TD
A[Applications / Agents]
B[REST API<br/>:8080]
C[MCP Server<br/>:8081]
D[Unified Gateway]
E[Firecrawl<br/>search · map · scrape · crawl]
F[Extract<br/>structured JSON]
G[Interact<br/>Playwright]
H[(Existing SearXNG)]
I[(Existing Ollama)]
J[Firecrawl Playwright]
K[Isolated browser sessions]
A --> B
A --> C
B --> D
C --> D
D --> E
D --> F
D --> G
E --> H
E -.-> J
F --> I
G -.-> I
G --> KDefault external-service addresses:
Ollama: http://host.docker.internal:11434
SearXNG: http://host.docker.internal:8088Firecrawl also runs its own internal Playwright service for page
rendering during scraping. The separate interact service exists
specifically for stateful browser automation.
Design principles
One stable contract --- callers use Web Research rather than directly coupling to Firecrawl, Ollama, SearXNG, or Playwright.
REST and MCP share backend behavior --- MCP delegates through the gateway rather than duplicating the implementation.
Reuse existing infrastructure --- Ollama and SearXNG remain independently managed services.
Deterministic first --- browser interaction prefers explicit selectors and accessible text before using an LLM to resolve ambiguity.
Structured extraction --- Ollama output is validated against caller-supplied JSON Schema.
Evidence preservation --- extraction responses include provenance metadata.
Browser isolation --- each interaction session receives an isolated Playwright browser context with a TTL.
Network containment --- browser navigation and subrequests are checked against SSRF policy; private/internal destinations are rejected by default.
Conservative automation --- potentially consequential clicks are blocked unless the caller explicitly allows them.
Private dependencies --- Firecrawl workers, Redis, RabbitMQ, PostgreSQL, Extract, Interact, and Playwright services are not published to the host.
Reproducible infrastructure --- third-party container images are pinned to tested immutable digests in
docker-compose.yaml.
Project layout
web-research/
├── docker-compose.yaml
├── .env.example
├── .dockerignore
├── .gitignore
├── Dockerfile.python
├── requirements.txt
├── Makefile
├── pytest.ini
├── common/
│ ├── http.py
│ └── logging.py
├── gateway/
│ └── app.py
├── extract/
│ └── app.py
├── interact/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── app.py
│ └── security.py
├── mcp/
│ └── server.py
├── mcp_ui/
│ ├── app.py
│ └── index.html
├── ui/
│ └── index.html
├── scripts/
│ ├── lib.sh
│ ├── test-all.sh
│ ├── test-endpoints.sh
│ ├── test-functionality.sh
│ ├── test-mcp.sh
│ └── test-security.sh
├── tests/
│ ├── live_mcp.py
│ ├── test_http_security.py
│ └── test_security.pyRuntime data is stored under data/ and is excluded from Git.
Quick Start
Prerequisites
You need:
Docker Engine
Docker Compose v2
curljqfor the shell examplesan existing Ollama service
an existing SearXNG service with JSON output enabled
at least one Ollama model suitable for structured JSON, such as
qwen2.5:14b
The default configuration assumes the existing services are reachable from Docker containers through host-published ports:
Ollama: http://host.docker.internal:11434
SearXNG: http://host.docker.internal:8088On Linux, services that need host access use:
extra_hosts:
- "host.docker.internal:host-gateway"This keeps Web Research independent of the Compose projects that run Ollama and SearXNG.
Verify existing services
From the Docker host, verify Ollama:
curl -sS http://127.0.0.1:11434/api/tags | jqVerify SearXNG:
curl -sS 'http://127.0.0.1:8088/search?q=firecrawl&format=json' \
| jq '.results[:2]'If either fails, fix that service before starting Web Research.
Configure
Create the environment file:
cp .env.example .envGenerate a gateway key:
openssl rand -hex 32At minimum, review:
GATEWAY_API_KEY=replace-with-a-long-random-value
POSTGRES_PASSWORD=replace-this-postgres-password
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=qwen2.5:14b
SEARXNG_ENDPOINT=http://host.docker.internal:8088
SEARXNG_ENGINES=
SEARXNG_CATEGORIES=generalSEARXNG_ENDPOINT is passed to Firecrawl, so /v1/search uses the
SearXNG instance you already operate.
OLLAMA_BASE_URL is used by the dedicated Extract service and by
Interact only when selector ambiguity requires LLM assistance.
Keep
GATEWAY_API_KEYconfigured for normal use. If the implementation permits a blank key, treat that only as an explicitly trusted-host/development mode. Do not expose an unauthenticated gateway to a LAN, tunnel, reverse proxy, or public network.
Do not commit .env.
Different Ollama or SearXNG ports
Change only the environment values:
OLLAMA_BASE_URL=http://host.docker.internal:11435
SEARXNG_ENDPOINT=http://host.docker.internal:8888Use Docker DNS instead
If the existing services share an external Docker network with this project, addresses can instead look like:
OLLAMA_BASE_URL=http://ollama:11434
SEARXNG_ENDPOINT=http://searxng:8080Attach the relevant Web Research containers to that external network. The supplied configuration uses host-published ports to keep the projects independent.
Image versioning
docker-compose.yaml pins Firecrawl and supporting third-party
infrastructure images to tested immutable image digests.
Example:
image: ghcr.io/firecrawl/firecrawl@sha256:<tested-digest>Do not casually replace pinned digests with latest in a production
deployment. Test upgrades first, update the digest deliberately, then
run the complete regression suite.
Start
Validate Compose:
docker compose configBuild and start:
docker compose up -d --buildor:
make upCheck status:
docker compose psFollow logs:
docker compose logs -f --tail=200Default interfaces:
REST API: http://127.0.0.1:8080
OpenAPI UI: http://127.0.0.1:8080/docs
MCP server: http://127.0.0.1:8081/mcp
Web UI: http://127.0.0.1:8080/ui
MCP Console: http://127.0.0.1:8080/ui/mcpThe published interfaces bind to loopback by default.
Web UI
Two browser UIs are included for development, testing, and manually exercising the platform. They do not replace the REST or MCP interfaces, and they link to each other via the nav tabs at the top of each page.
Endpoint Tester
Served by the gateway at /ui. It provides a tab per endpoint (Search,
Map, Scrape, Crawl, Extract, Interact) with schema-driven parameter
controls, a built JSON body you can edit, and a submit step.
http://127.0.0.1:8080/uiMCP Console
Served by the gateway at /ui/mcp. It connects to the MCP
server, lists the available tools, renders each tool's parameters from
its input schema, and lets you run a tool once or assemble a multi-step
workflow (with {{lastData.*}} placeholders between steps) and execute
or export it as JSON.
http://127.0.0.1:8080/ui/mcpWhen authentication is enabled, supply the configured gateway API key in the Endpoint Tester. The MCP Console injects the key server-side for its proxied requests, so it does not need to embed the secret into the page.
UI screenshots
Screenshots intentionally pending. The UI is still being refined. These paths are reserved so the final screenshots can be added without restructuring the README.

Figure 1: MCP tab with the session-aware initialize, list-tools, and call-tool flow.

Figure 2: Interact tab showing a stateful browser session and discovered interactive elements.

Figure 3: Interact Plan tab showing a natural-language goal and generated browser-action plan.
REST API
Use REST for conventional applications, deterministic LangGraph nodes, shell scripts, and integrations that already use HTTP APIs.
Set the base URL and key once:
export WRS='http://127.0.0.1:8080'
export WRS_KEY='replace-with-your-GATEWAY_API_KEY'Authenticated requests use:
Authorization: Bearer <GATEWAY_API_KEY>Interactive OpenAPI documentation is available at:
http://127.0.0.1:8080/docsEndpoint summary
Method Endpoint Purpose
GET /health Gateway/dependency health
POST /v1/search Search through Firecrawl + SearXNG
POST /v1/map Discover URLs on a site
POST /v1/scrape Scrape a single URL
POST /v1/crawl Start a crawl
GET /v1/crawl/{job_id} Check crawl status/results
POST /v1/extract Structured extraction through Ollama
POST /v1/interact/sessions Create a browser session
POST /v1/interact/sessions/{id}/navigate Navigate a browser session
POST /v1/interact/sessions/{id}/action Click/type/press/select/wait/scroll
GET /v1/interact/sessions/{id}/text Read page text
GET /v1/interact/sessions/{id}/screenshot Capture a PNG screenshot
DELETE /v1/interact/sessions/{id} Close a browser session
Search
curl -sS "$WRS/v1/search" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"query": "accessible hotels Vancouver BC",
"limit": 5
}' | jqFlow:
REST client -> Gateway -> Firecrawl -> existing SearXNGSearch and scrape result content in one request:
curl -sS "$WRS/v1/search" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"query": "Royal Caribbean Alaska 2027",
"limit": 5,
"scrapeOptions": {
"formats": ["markdown"],
"onlyMainContent": true
}
}' | jqMap a site
curl -sS "$WRS/v1/map" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"limit": 100
}' | jqTarget a topic:
curl -sS "$WRS/v1/map" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://www.royalcaribbean.com",
"search": "Alaska cruise",
"limit": 50
}' | jqScrape
curl -sS "$WRS/v1/scrape" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"formats": ["markdown"],
"onlyMainContent": true
}' | jqMultiple formats can be requested when supported by the pinned Firecrawl release:
curl -sS "$WRS/v1/scrape" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"formats": ["markdown", "links", "screenshot"],
"onlyMainContent": true,
"waitFor": 1000
}' | jqInspect target-page metadata as well as the gateway HTTP status; a successful gateway request does not necessarily mean the target site returned useful content.
Crawl
Start:
curl -sS "$WRS/v1/crawl" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"limit": 50
}' | jqPoll:
JOB_ID='<returned-job-id>'
curl -sS "$WRS/v1/crawl/$JOB_ID" \
-H "Authorization: Bearer $WRS_KEY" | jqLimit crawl scope where appropriate:
curl -sS "$WRS/v1/crawl" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"limit": 20,
"maxDepth": 2,
"scrapeOptions": {
"formats": ["markdown"],
"onlyMainContent": true
}
}' | jqStructured extraction
The gateway can scrape a URL and send the resulting content to the dedicated Ollama extraction service:
flowchart LR
A[URL] --> B[Firecrawl scrape]
B --> C[Content]
C --> D[Extract service]
D --> E[Ollama]
E --> F[JSON Schema validation]
F --> G{Valid?}
G -->|yes| H[Structured JSON + provenance]
G -->|no, retry available| EExample:
curl -sS "$WRS/v1/extract" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"instruction": "Extract the page title and a short summary using only the supplied page content.",
"schema": {
"type": "object",
"properties": {
"title": {"type": ["string", "null"]},
"summary": {"type": ["string", "null"]}
},
"required": ["title", "summary"],
"additionalProperties": false
}
}' | jqTypical response:
{
"data": {
"title": "Example Domain",
"summary": "..."
},
"provenance": {
"source_url": "https://example.com",
"content_sha256": "...",
"content_chars": 1234,
"truncated": false,
"model": "qwen2.5:14b",
"attempts": 1,
"generated_at": "..."
}
}Extract request fields
Field Required Description
url one of url or Page to scrape before
content extraction
content one of url or Raw text/markdown to
content extract directly
instruction yes What to extract; be
explicit
schema yes JSON Schema for the
required output
model no Override OLLAMA_MODEL
for this request
max_retries no Override the configured
validation retry count
Use additionalProperties: false when you want tightly constrained
output.
Extract content you already have
curl -sS "$WRS/v1/extract" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"content": "Acme Inc. is headquartered in Vancouver, British Columbia.",
"instruction": "Extract company and headquarters.",
"schema": {
"type": "object",
"properties": {
"company": {"type": ["string", "null"]},
"headquarters": {"type": ["string", "null"]}
},
"required": ["company", "headquarters"],
"additionalProperties": false
}
}' | jqThis avoids an unnecessary scrape.
Browser interaction
Browser automation is session based.
Create a session
SESSION_ID=$(curl -sS "$WRS/v1/interact/sessions" \
-X POST \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{}' | jq -r '.session_id')
echo "$SESSION_ID"Navigate
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/navigate" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}' | jqClick
Prefer explicit selectors when you know them:
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/action" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"action": "click",
"selector": "a"
}' | jqWhen a selector is not known, a natural-language description can be used:
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/action" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"action": "click",
"description": "Search"
}' | jqInteract tries deterministic accessible-text/label matching first. Ollama is used only when the target remains ambiguous.
Type
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/action" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"action": "type",
"selector": "input[name=q]",
"value": "British Columbia"
}' | jqRead page text
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/text" \
-H "Authorization: Bearer $WRS_KEY" | jqScreenshot
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/screenshot" \
-H "Authorization: Bearer $WRS_KEY" \
-o screenshot.pngClose
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID" \
-X DELETE \
-H "Authorization: Bearer $WRS_KEY" | jqComplete browser flow
# 1. Create a session.
SESSION_ID=$(curl -sS "$WRS/v1/interact/sessions" \
-X POST \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{}' | jq -r '.session_id')
# 2. Navigate.
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/navigate" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com","wait_until":"networkidle"}' | jq
# 3. Read the page.
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/text" \
-H "Authorization: Bearer $WRS_KEY" | jq
# 4. Capture a screenshot.
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/screenshot" \
-H "Authorization: Bearer $WRS_KEY" \
-o screenshot.png
# 5. Close the session.
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID" \
-X DELETE \
-H "Authorization: Bearer $WRS_KEY" | jqConsequential clicks
The Interact service applies a conservative policy to clicks whose target text appears consequential. A protected click requires:
{
"allow_consequential": true
}Example:
curl -sS "$WRS/v1/interact/sessions/$SESSION_ID/action" \
-H "Authorization: Bearer $WRS_KEY" \
-H 'Content-Type: application/json' \
-d '{
"action": "click",
"selector": "text=Confirm booking",
"allow_consequential": true
}' | jqThis is a guardrail for clicks, not a complete authorization system for every possible browser side effect. Applications and agents remain responsible for obtaining explicit user authorization before consequential actions.
MCP
The MCP server exposes Web Research capabilities to MCP-capable clients over Streamable HTTP.
Endpoint:
http://127.0.0.1:8081/mcpThe MCP server delegates to the same REST gateway used by conventional clients. It does not maintain a separate implementation of search, scrape, extraction, or browser logic.
MCP tools
Tool Purpose
about Describe the stack
search Search through Firecrawl + SearXNG
map_site Discover URLs on a site
scrape Scrape a URL
crawl Start a crawl
crawl_status Poll crawl status/results
extract Structured extraction through Ollama
browser_create_session Create an isolated browser session
browser_navigate Navigate a browser session
browser_action Click/type/press/select/wait/scroll
browser_text Read page text
browser_screenshot Return a screenshot
browser_close_session Destroy a browser session
FastMCP Python client
Install:
pip install fastmcpExample:
import asyncio
from fastmcp import Client
async def main():
async with Client("http://127.0.0.1:8081/mcp") as client:
tools = await client.list_tools()
print([tool.name for tool in tools])
result = await client.call_tool(
"search",
{
"query": "accessible hotels Vancouver BC",
"limit": 5,
},
)
print(result)
asyncio.run(main())MCP examples
Search
Tool:
searchArguments:
{
"query": "Royal Caribbean Alaska 2027",
"limit": 10
}This reaches the same backend path as:
POST /v1/searchScrape
Tool:
scrapeArguments:
{
"url": "https://example.com",
"formats": ["markdown"]
}Extract
Tool:
extractArguments:
{
"url": "https://example.com",
"instruction": "Extract the page title and summary.",
"schema": {
"type": "object",
"properties": {
"title": {"type": ["string", "null"]},
"summary": {"type": ["string", "null"]}
},
"required": ["title", "summary"],
"additionalProperties": false
}
}Browser
Create:
browser_create_sessionSave the returned session_id, then navigate:
browser_navigate{
"session_id": "<session-id>",
"url": "https://example.com"
}Act:
browser_action{
"session_id": "<session-id>",
"action": "click",
"description": "More information"
}Read:
browser_text{
"session_id": "<session-id>"
}Close:
browser_close_session{
"session_id": "<session-id>"
}Generic MCP client configuration
For clients that accept a Streamable HTTP server URL:
http://127.0.0.1:8081/mcpThe exact configuration syntax depends on the MCP client.
Choosing REST vs MCP
Use REST when:
calling from application code
building deterministic LangGraph nodes
integrating with
httpx,curl, FastAPI, or another HTTP clientyou want explicit request/response control
you want OpenAPI documentation
Use MCP when:
an LLM or agent should discover tools dynamically
the calling application already supports MCP
you want the model to choose among search, scrape, extract, and browser tools
you want a single tool server rather than hard-coded HTTP nodes
Both interfaces reach the same underlying Web Research services.
LangGraph integration
For deterministic LangGraph nodes, point all web-research HTTP calls at:
http://<docker-host>:8080For example:
search node -> POST /v1/search
map node -> POST /v1/map
scrape node -> POST /v1/scrape
crawl node -> POST /v1/crawl
extract node -> POST /v1/extract
interact node -> /v1/interact/...Do not let individual nodes depend directly on Firecrawl, Ollama, or Playwright URLs.
For an MCP-driven agent:
http://<docker-host>:8081/mcpThis boundary lets backend implementations change without rewriting the graph.
Security
Security at a glance
Web Research is designed for self-hosting and applies defense in depth:
Control Purpose
Loopback-only published interfaces Avoid accidental network exposure by default
Gateway bearer authentication Protect REST operations when configured
Internal-only dependency services Keep databases, queues, workers, and browser backends off host ports
Isolated Playwright contexts Separate browser sessions
Browser-session TTL/concurrency Bound resource use controls
SSRF destination validation Reject private/internal browser destinations
Context-level browser request Apply network policy across the interception browser context
Service-worker restrictions Reduce request paths that can bypass interception
WebSocket restrictions Reduce unguarded browser network channels
Consequential-click guard Require explicit opt-in for protected click targets
Read-only filesystems/capability Reduce container privilege dropping where configured
Immutable third-party image digests Improve reproducibility and supply-chain control
Security regression tests Verify key controls remain enforced
These controls reduce risk; they do not make arbitrary browser automation safe to expose directly to the public Internet.
Authentication
Configure a strong GATEWAY_API_KEY:
openssl rand -hex 32Store it in .env:
GATEWAY_API_KEY=<generated-value>Do not commit .env.
If the application currently permits GATEWAY_API_KEY to be blank,
treat that as trusted-host/development-only behavior. Before
exposing REST or MCP beyond localhost, authentication should be
mandatory at the gateway and/or a trusted reverse-proxy boundary.
Network exposure
By default:
127.0.0.1:8080 -> REST gateway / Web UI / MCP Console UI
127.0.0.1:8081 -> MCP serverThe following remain private to the Compose network:
Firecrawl API/workers
Firecrawl Playwright
Extract
Interact
Redis
RabbitMQ
PostgreSQL
Do not publish those services merely for convenience.
SSRF protection
Browser automation is a high-risk network boundary because a browser can otherwise be used to probe internal infrastructure.
Interact therefore validates navigation and browser network destinations and rejects non-public destinations by default, including categories such as:
loopback
RFC1918/private networks
link-local addresses
cloud metadata-style destinations
multicast/reserved/unspecified destinations
disallowed URL schemes
URLs containing embedded credentials
Browser request interception is applied at the browser-context level so the policy covers the session rather than only one page. Service workers and WebSocket behavior are restricted so they cannot trivially bypass the ordinary request guard.
Keep these controls enabled.
Consequential actions
The built-in policy specifically protects potentially consequential
clicks based on the target text and requires explicit opt-in through
allow_consequential.
It is intentionally a guardrail, not a universal authorization mechanism. Other browser operations can also have side effects. Agents should not perform purchases, bookings, submissions, deletions, messages, transfers, publication, or other consequential actions without explicit application-level/user authorization.
Secrets
Never commit
.env.Use a long random gateway key.
Do not embed the gateway key into static UI HTML.
Do not put credentials in browser-navigation URLs.
Prefer a secret manager when deploying beyond a single trusted host.
Rotate credentials if they are exposed in logs, shell history, screenshots, or source control.
Reverse proxies and tunnels
If REST or MCP is exposed beyond localhost:
require authentication
terminate TLS
preserve the real client identity only from trusted proxy hops
set request-body limits
add rate/concurrency controls
keep all backend services private
restrict source networks where practical
do not expose the browser backend directly
Testing
The repository includes unit/regression tests and live end-to-end scripts.
Test prerequisites
Start the stack:
docker compose up -d --buildThe shell tests require curl, python3, and Docker. They read
GATEWAY_API_KEY from .env when it is not already exported.
Defaults:
REST gateway: http://127.0.0.1:8080
MCP endpoint: http://127.0.0.1:8081/mcp
Public test URL: https://example.com
Search query: example domainOverride:
GATEWAY_URL=http://127.0.0.1:8080 \
MCP_URL=http://127.0.0.1:8081/mcp \
TEST_URL=https://www.iana.org \
SEARCH_QUERY='IANA reserved domains' \
./scripts/test-all.shUnit and regression tests
make test-unitor:
make testThe regression suite covers controls such as:
private/loopback/link-local SSRF blocking
rejection of non-HTTP URL schemes
rejection of credentials embedded in URLs
mixed public/private DNS resolution
browser host allowlist behavior
consequential-click detection
gateway API-key enforcement
request-ID generation/preservation
Live security tests
make test-securityor:
./scripts/test-security.shThese tests exercise the deployed REST contract and verify rejection of representative unsafe destinations, including localhost/private-network/metadata-style targets and unsafe URL forms.
Keep security tests as regression tests: if a dependency upgrade causes one to fail, investigate before deploying the upgrade.
Live REST functionality
make test-functionalityor:
./scripts/test-functionality.shThe live suite exercises:
Test Path
Health client -> gateway Request ID gateway middleware Search gateway -> Firecrawl -> SearXNG Map gateway -> Firecrawl Scrape gateway -> Firecrawl -> Firecrawl Playwright Crawl gateway -> Firecrawl queue/workers Extract gateway -> Extract -> Ollama -> JSON Schema validation Browser create gateway -> Interact -> Playwright Browser navigate Interact + SSRF guard -> public URL Browser text Playwright DOM extraction Screenshot Playwright PNG generation Browser close session cleanup
The extraction test supplies content directly so the Ollama extraction path is tested independently of scraping.
Skip Internet/SearXNG-dependent checks when appropriate:
SKIP_NETWORK_TESTS=1 ./scripts/test-functionality.shMCP smoke test
make test-mcpor:
./scripts/test-mcp.shThe smoke test connects to the actual Streamable HTTP MCP endpoint,
initializes a client session, lists tools, verifies expected tools, and
calls about.
Wider endpoint regression
./scripts/test-endpoints.shWithout network-dependent tests:
SKIP_NETWORK_TESTS=1 ./scripts/test-endpoints.shRun everything
make test-allor:
./scripts/test-all.shA successful run ends with:
ALL TESTS PASSEDUse the complete suite before accepting upgrades to Firecrawl, Playwright, Python dependencies, or other infrastructure components.
Operations and troubleshooting
Gateway health
curl -sS http://127.0.0.1:8080/health | jqIf health becomes authenticated in a future configuration, add:
-H "Authorization: Bearer $WRS_KEY"Verify Ollama from Extract
docker compose exec extract python - <<'PY'
import urllib.request
print(
urllib.request.urlopen(
"http://host.docker.internal:11434/api/tags",
timeout=5,
).read()[:500]
)
PYVerify SearXNG from Firecrawl
docker compose exec firecrawl-api node -e \
"fetch('http://host.docker.internal:8088/search?q=test&format=json').then(r=>{console.log(r.status);return r.text()}).then(t=>console.log(t.slice(0,500))).catch(e=>{console.error(e);process.exit(1)})"Search returns no results
Check SearXNG directly:
curl -sS 'http://127.0.0.1:8088/search?q=test&format=json' \
| jq '.results | length'Inspect Firecrawl:
docker compose logs --tail=200 firecrawl-apiConfirm configuration:
docker compose exec firecrawl-api env | grep '^SEARXNG_'Extraction fails
Confirm Ollama sees the configured model:
curl -sS http://127.0.0.1:11434/api/tags \
| jq -r '.models[].name'Inspect Extract logs:
docker compose logs --tail=200 extractBrowser fails to start
docker compose logs --tail=200 interactCheck available RAM/shared memory and confirm the pinned Playwright image is available.
MCP fails to connect
Check:
docker compose ps
docker compose logs --tail=200 mcpThen verify the endpoint from the Docker host:
http://127.0.0.1:8081/mcpRemember that MCP Streamable HTTP is not tested by simply browsing to the endpoint as though it were a normal HTML page; use an MCP client or the supplied smoke test.
Request correlation
The gateway creates or preserves x-request-id and forwards it to
downstream services. Use the request ID to correlate gateway, Extract,
and Interact logs.
Production recommendations
Before exposing Web Research outside a trusted host:
Require authentication. Do not rely on an accidentally blank
GATEWAY_API_KEY.Keep REST/MCP behind TLS and a trusted access boundary. A reverse proxy, VPN, or zero-trust access layer is preferable to direct Internet exposure.
Keep backend services private. Do not publish Redis, RabbitMQ, PostgreSQL, Firecrawl internals, Extract, Interact, or Playwright.
Keep SSRF protections enabled. Treat failures in SSRF regression tests as release blockers.
Set ingress request-body limits. Protect the gateway from unexpectedly large requests before the application parses them.
Add rate and concurrency controls. Crawls, LLM inference, screenshots, and browser sessions are resource-intensive.
Set container resource limits appropriate to the host. Pay particular attention to Firecrawl workers, Playwright, and Ollama.
Use strong secrets and rotate them when needed.
Pin tested images and dependencies. Avoid floating
latesttags in production.Test upgrades before deployment. Run
make test-allagainst the candidate version.Back up persistent Firecrawl state if the deployment depends on queued/history data.
Aggregate logs and metrics. Preserve request IDs for correlation.
Monitor disk, memory, queue depth, browser-session count, and Ollama load.
Define browser-session concurrency based on actual RAM/CPU capacity.
Require explicit authorization for consequential actions.
allow_consequential=trueshould never be set automatically by an agent without the application's authorization policy.Keep Ollama and SearXNG independently managed so Web Research can be upgraded without replacing them.
Stage security-sensitive changes. Browser/network-policy changes deserve live regression testing before production rollout.
Capability summary
Once running, Web Research provides a single self-hosted capability layer:
%%{init: {'theme': 'default', 'flowchart': {'useMaxWidth': true}}}%%
flowchart TD
A[Web Research]
A --> B[Discovery]
A --> C[Content]
A --> D[Interaction]
B --> E[search · map]
C --> F[scrape · crawl · extract]
D --> G[stateful browser]
E --> H[(SearXNG + Firecrawl)]
F --> I[(Firecrawl + Ollama)]
G --> J[(Playwright)]Call it through:
REST -> http://127.0.0.1:8080
MCP -> http://127.0.0.1:8081/mcp
UI -> http://127.0.0.1:8080/ui
MCP UI -> http://127.0.0.1:8080/ui/mcpThe key architectural boundary is simple:
Applications depend on the Web Research contract, not directly on Firecrawl, SearXNG, Ollama, or Playwright.
That keeps the research layer self-hosted, testable, replaceable, and usable from both deterministic application code and agentic systems.
This server cannot be deployed
Maintenance
Related MCP Connectors
Scrape, crawl and search the web for AI agents via MCP.
Web MCP: scrape/crawl sites, web search, brand assets, app stores, YouTube, Reddit, Hacker News.
All public upAPI operations as MCP tools: web scraping, search, screenshots, PDF, OCR and more.
One MCP for the Web. Easily search, crawl, navigate, and extract websites without getting blocked.…
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables file system operations, web scraping, and AI-powered search through MCP tools for use by LLM agents.1-
- AlicenseCqualityCmaintenanceProvides browser automation and web scraping as MCP tools, enabling autonomous URL ingestion, crawling, extraction, and anti-bot handling with interactive browser control.625MIT
- AlicenseAqualityAmaintenanceProvides 27 MCP-native tools for web scraping, crawling, deep research, and autonomous extraction, delivering clean Markdown and structured JSON from any website.311,766 npm2MIT
- AlicenseNot gradedqualityCmaintenanceEnables MCP clients to perform keyless web searches, extract fully rendered pages into clean Markdown, and automate a shared Chrome browser through page navigation, clicking, typing, reading, screenshots, and backtracking—all without API keys.MIT