Building a Web Search MCP Server — DDG, Brave, SQLite, and SSRF Hardening
Part 3 of 6: Giving a local language model access to the live web, safely
Building a Web Search MCP Server — DDG, Brave, SQLite, and SSRF Hardening
Part 3 of 6: Giving a local language model access to the live web, safely
A local language model’s knowledge ends at its training cutoff. Ask it about a library release from last month, a security advisory from last week, or today’s news, and it will either admit ignorance or confidently make something up. The solution is to give it a web search tool.
Cloud providers solve this by building search into the API. For a local model, you have to wire it yourself. The Model Context Protocol (MCP) is the standard way to do this: define a tool the model can call, run it as a subprocess, and let the model decide when a search is useful.
This article describes the web-search MCP server I built for this stack.
Design goals
Before writing a line of code, I listed what I actually needed:
- No mandatory API key. DuckDuckGo is free and keyless. It should be the default.
- Fresh results when needed. DDG’s index can be stale. Brave Search has a fast-refresh index and a generous free tier. Use it as a fallback.
- No hammering search APIs. Cache results in SQLite. Repeated queries for the same thing should hit the cache.
- Rate limiting. A runaway agent could burn through 200 searches before I notice. Cap it.
- Security. An MCP server that fetches arbitrary URLs is a potential SSRF vector. Harden it properly.
- Full test coverage. All network calls mocked. No live HTTP in tests.
Architecture
OpenCode

The server communicates over stdio using the MCP protocol. OpenCode and aichat discover it through their MCP configuration and call it automatically.
The three tools
web_search
async def web_search(
query: str,
max_results: int = 10,
force_provider: str | None = None,
) -> SearchResponse:
The model calls this with a query string. The server:
- Checks the daily limit (atomic, prevents TOCTOU race).
- Checks the SQLite cache. Returns cached result if fresh.
- Calls DuckDuckGo.
- Runs quality heuristics on the results. If they look poor, or the query is freshness-sensitive (contains words like “latest”, “2025”, “release”, etc.), falls back to Brave.
- Records the search in the usage log.
- Stores the result in cache.
The force_provider parameter lets the model explicitly request DDG or Brave when it has a preference.
fetch_markdown
async def fetch_markdown(url: str, max_chars: int = 20000) -> FetchResponse:
Fetches a URL and converts the HTML to Markdown using MarkItDown. The model uses this to read a specific page after getting URLs from web_search. The 20,000-character default fits comfortably in a 128k context window alongside other content.
search_status
async def search_status() -> StatusResponse:
Returns current provider availability, cache statistics, and today’s search count. Useful for debugging and for checking whether you are approaching the daily limit.
Provider strategy
DuckDuckGo (primary)
from ddgs import DDGS
async def search(self, query: str, max_results: int) -> list[SearchResult]:
with DDGS() as ddg:
raw = ddg.text(query, max_results=max_results)
return [SearchResult(title=r[“title”], url=r[“href”], snippet=r[“body”]) for r in raw]
No API key. Rate-limited by DDG but generous for personal use. The ddgs package (note: not duckduckgo_search — that package was renamed) handles the scraping.
Common pitfall: The package was renamed from duckduckgo-search to ddgs around version 6. If you see 0 results with a RuntimeWarning, this is the cause. Fix your pyproject.toml:
dependencies = [
“ddgs>=6.0”,
…
]
And update the import:
Wrong (old package):
from duckduckgo_search import DDGS
Correct:
from ddgs import DDGS
Brave Search (fallback)
Brave’s Search API has a free tier (2,000 queries/month) and a fast-refresh index. The API key goes in ~/.config/web-search-mcp/config.json:
{
“brave_api_key”: “YOUR_BRAVE_API_KEY”
}
The file permissions should be 600:
chmod 600 ~/.config/web-search-mcp/config.json
The server reads this file at startup via config.py. The key is never passed through environment variables (which can be seen by other processes) or through the OpenCode MCP env block (which is logged by some tools).
SQLite caching
Results are cached in ~/.cache/web-search-mcp/search_cache.sqlite3.
The cache schema:
CREATE TABLE cache (
provider TEXT NOT NULL,
query TEXT NOT NULL,
max_results INTEGER NOT NULL,
results TEXT NOT NULL, — JSON
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
PRIMARY KEY (provider, query, max_results)
);
Default TTL is 24 hours. Freshness queries (those containing recent-year patterns or words like “latest”, “today”, “now”) bypass the cache entirely — stale results for time-sensitive queries are worse than no cache.
Important: if you migrate from the old duckduckgo_search package to ddgs, clear the cache. Stale entries from the broken package will return empty results and cause Brave to never initialise:
sqlite3 ~/.cache/web-search-mcp/search_cache.sqlite3 “DELETE FROM cache;”
Security hardening
An MCP server that fetches arbitrary URLs is an SSRF risk. A malicious prompt could direct the model to call fetch_markdown(“http://169.254.169.254/latest/meta-data/") and exfiltrate AWS metadata, or probe your local network.
The hardening applied:
DNS pinning
Resolve the hostname once, pin the IP address, and reject the connection if the IP is in a private range. This prevents DNS rebinding attacks where a public hostname resolves to a private IP on the second lookup.
# Simplified from fetch.py
ip = socket.gethostbyname(hostname)
if _is_private_ip(ip):
raise PrivateAddressError(f”Resolved to private address: {ip}”)
# Use the pinned IP for the actual HTTP request
Private IP blocklist
PRIVATE_RANGES = [
ipaddress.ip_network(“127.0.0.0/8”),
ipaddress.ip_network(“10.0.0.0/8”),
ipaddress.ip_network(“172.16.0.0/12”),
ipaddress.ip_network(“192.168.0.0/16”),
ipaddress.ip_network(“169.254.0.0/16”), # AWS/GCP metadata
ipaddress.ip_network(“::1/128”),
ipaddress.ip_network(“fe80::/10”),
]
URL scheme allowlist
Only http and https are allowed. file://, ftp://, etc. raise an error before any network call.
No redirects
follow_redirects=False on all httpx requests. A redirect to a private IP would bypass the DNS check.
Download cap
5 MB cap on the streaming download before MarkItDown conversion. Prevents a model from inadvertently triggering a huge download.
API key scrubbing
A custom SanitisedFormatter strips the Brave API key from all log output. Exception chaining is suppressed (raise … from None) where the key could appear in a traceback.
Rate limiting
The UsageLimiter uses an atomic SQLite operation to check and record each search in one transaction:
INSERT OR IGNORE INTO usage_log (date, provider, query_hash, count)
VALUES (?, ?, ?, 0);
UPDATE usage_log SET count = count + 1
WHERE date = ? AND provider = ? AND query_hash = ?;
SELECT SUM(count) FROM usage_log WHERE date = ?;
This prevents a TOCTOU race where two concurrent searches both read “limit not reached” and both proceed past the limit. The default is 200 searches per day, resetting at UTC midnight.
Installation
The server is packaged as a Python project and installed via uv tool:
uv tool install /path/to/web-search-mcp
This puts web-search-mcp in ~/.local/bin/. The binary is a proper system command with no dependency on the source directory.
To update after code changes:
uv tool install — reinstall /path/to/web-search-mcp
Wiring into a client
The server communicates over stdio. Any MCP-compatible client can use it. See Article 6 for the OpenCode configuration. For now, you can test it directly:
# Start the server and send a tool list request
echo ‘{“jsonrpc”:”2.0",”id”:1,”method”:”tools/list”}’ | web-search-mcp
You should see web_search, fetch_markdown, and search_status in the response.
# Send a search request
echo ‘{
“jsonrpc”:”2.0",”id”:2,
“method”:”tools/call”,
“params”:{
“name”:”web_search”,
“arguments”:{“query”:”MLX Apple Silicon 2025",”max_results”:3}
}
}’ | web-search-mcp
Both DDG and Brave returning results means the stack is working.
메타데이터
- post_id
- 63d299a7d4e5
- slug
- building-a-web-search-mcp-server-ddg-brave-sqlite-and-ssrf-hardening-63d299a7d4e5
- url
- https://medium.com/@sami.bister/building-a-web-search-mcp-server-ddg-brave-sqlite-and-ssrf-hardening-63d299a7d4e5
- canonical_url
- https://medium.com/@sami.bister/building-a-web-search-mcp-server-ddg-brave-sqlite-and-ssrf-hardening-63d299a7d4e5
- author_url
- https://medium.com/@sami.bister
- status
- ok
- fetched_at
- 2026-06-09 15:37:30