readable-mcp
Converts any URL into clean, LLM-ready Markdown, text, or HTML with production-grade features like SSRF protection, rate limiting, retries, caching, and structured error handling.
README
readable-mcp
Turn any URL into clean, LLM-ready Markdown. A production MCP server: rate-limited, retried, cached, and SSRF-safe.
LLMs choke on raw HTML — nav bars, ad markup, and tracking scripts burn tokens and bury the actual content. Worse, a naïve "just fetch the URL" tool is a server-side request forgery (SSRF) hole waiting to be pointed at 169.254.169.254 or your internal network. readable-mcp solves both: it extracts the real content as Markdown/text/cleaned-HTML, and it does the fetching the way a production service would — validated, rate-limited, retried with backoff, cached, and observable. This is not a weekend demo; every outbound request assumes the network is hostile.
Features
Production qualities first — these are what make it safe to point at the open web:
- SSRF-safe fetching — resolves the hostname and refuses any address in private, loopback, link-local, or reserved space, so
localhost, raw private IPs, and public hostnames that resolve inward are all blocked. - Token-bucket rate limiting — a single shared limiter (default 5 req/s, burst 10) gates every outbound request, including inside batch calls.
- Exponential-backoff retries — retries timeouts, connection errors,
429, and5xxwith jittered backoff, and honorsRetry-Afteron429. Never retries other 4xx (those are caller errors). - Explicit timeouts — separate connect/read and a hard total ceiling; a request can never hang forever.
- TTL caching — in-memory cache keyed by normalized URL + format (default 900s); cache hits are flagged
from_cache=true. - Structured typed errors — tools never raise to the client; failures come back as a typed
{code, message, retryable}. - First-class partial-success batch — one bad URL never fails the batch; each URL gets its own success/error slot.
- JSON observability logs — one structured line per call (request id, tool, host, latency, cache hit/miss, outcome) — never response bodies or secrets.
And the capability itself:
- Clean content extraction —
trafilaturastrips boilerplate and pulls title, author, and publish date; output as Markdown, plain text, or cleaned HTML.
Architecture
┌────────────┐ extract_url / extract_batch / get_stats
│ MCP client │──────────────────────────────────────────────┐
│ (Claude, │ │
│ Cursor…) │ ▼
└────────────┘ ┌───────────────────┐
│ server.py (tools)│ thin orchestration
└─────────┬─────────┘
│
┌──────────────┬───────────────┬────────────┬─────────┴────────┐
▼ ▼ ▼ ▼ ▼
┌─────────────┐ ┌────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ validation │ │ rate_limiter│ │ cache │ │ http_client │ │ extractor │
│ + SSRF │→│ token bucket│→│ TTL (hit │→│ retry+timeout│→│ trafilatura │
│ guard │ │ acquire() │ │ / miss) │ │ httpx │ │ → Markdown │
└─────────────┘ └────────────┘ └──────────┘ └──────────────┘ └──────┬───────┘
▼
┌───────────────────────┐
│ typed ExtractionResult │
│ or ExtractionError │
└───────────────────────┘
Validation runs before anything touches a socket; the rate limiter and cache sit in front of the network; every failure is funneled into a typed result.
Production handling, not a demo
Four patterns pulled straight from the source — the reason this is portfolio-grade.
1. SSRF guard that resolves before it trusts
A string check on the hostname is not enough: an attacker can register a public domain whose DNS points at 169.254.169.254. We resolve first, then check every resolved IP against the non-public ranges.
# validation.py
host = parts.hostname
try:
literal = ipaddress.ip_address(host) # raw-IP host?
candidates = [str(literal)]
except ValueError:
candidates = _resolve_addresses(host) # otherwise resolve DNS
for ip in candidates:
if not _is_public_ip(ip): # private/loopback/link-local/reserved
raise ValidationError(
ErrorCode.BLOCKED_HOST,
f"Refusing to fetch {host!r}: resolves to non-public address {ip}.",
)
Why it matters: this is the difference between a fetch tool and an internal-network proxy for whoever controls the input. It's the first thing a security-minded reviewer checks.
2. Async token-bucket rate limiter
One shared bucket throttles every outbound request — including the concurrent fetches inside extract_batch — so the server stays a polite citizen under load.
# rate_limiter.py
async def acquire(self, tokens: float = 1.0) -> None:
while True:
async with self._lock:
now = asyncio.get_event_loop().time()
self._refill(now)
if self._tokens >= tokens:
self._tokens -= tokens
return
wait = (tokens - self._tokens) / self._rate
await asyncio.sleep(wait) # sleep outside the lock
Why it matters: tokens refill continuously and the wait happens outside the lock, so many coroutines can share one limiter without deadlocking or over-drawing the bucket.
3. Retry with backoff that honors Retry-After
Transient failures (429, 5xx, timeouts, connection errors) are retried with jittered exponential backoff; a 429 with a Retry-After header is obeyed exactly. Other 4xx are returned immediately — retrying a 404 is just wasted requests.
# http_client.py
if status in _RETRYABLE_STATUS:
if not is_last:
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
delay = retry_after if retry_after is not None else _backoff_delay(
attempt, self._settings.retry_base_delay
)
await self._sleep(delay)
continue
raise last_error
if status >= 400:
raise FetchError(ErrorCode.HTTP_ERROR, ..., retryable=False) # caller error, no retry
Why it matters: backoff with jitter prevents thundering-herd retries, and honoring Retry-After is what keeps you from getting hard-blocked by the upstream.
4. Structured errors — the client never sees a stack trace
Every failure path converges on one typed shape. Tools return it instead of raising, so a bad URL degrades gracefully instead of crashing the tool call.
# server.py
def _error(url: str, code: ErrorCode, message: str, *, retryable: bool) -> ExtractionError:
return ExtractionError(
url=url, error=ErrorDetail(code=code, message=message, retryable=retryable)
)
Why it matters: the calling LLM can branch on error.code and error.retryable programmatically, and in a batch one bad URL simply occupies its own error slot.
Quickstart
With uv (recommended):
git clone https://github.com/tommypj/readable-mcp.git
cd readable-mcp
uv sync # install
uv run readable-mcp # run the server over stdio
With pip:
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
readable-mcp
The server speaks the MCP stdio transport, so it's normally launched by an MCP client (below) rather than run by hand.
Use it in Claude Desktop / Claude Code
Add this to your claude_desktop_config.json (mirrors examples/claude_desktop_config.json):
{
"mcpServers": {
"readable": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/readable-mcp", "run", "readable-mcp"]
}
}
}
Config file locations:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Claude Code:
claude mcp add readable -- uv --directory /absolute/path/to/readable-mcp run readable-mcp - Cursor: add the same
mcpServersblock under Settings → MCP.
Restart the client, then ask: "Extract the main content of https://example.com as markdown."
Tools reference
extract_url(url, output_format="markdown")
Fetch one URL and return its main content plus metadata.
url— anhttp/httpsURL. Private/loopback/link-local hosts are refused.output_format—"markdown"(default),"text", or"html"(cleaned).- Returns
ExtractionResultorExtractionError. - Error codes:
INVALID_URL,BLOCKED_HOST,FETCH_TIMEOUT,HTTP_ERROR,EXTRACTION_FAILED,RATE_LIMITED.
// example result (trimmed)
{
"url": "https://example.com/",
"final_url": "https://example.com/",
"title": "Example Domain",
"author": null,
"published": null,
"word_count": 17,
"output_format": "markdown",
"content": "This domain is for use in documentation examples...",
"from_cache": false,
"fetched_at": "2026-06-23T10:01:22.481+00:00"
}
extract_batch(urls, output_format="markdown")
Extract up to 10 URLs concurrently; partial success is first-class.
urls— list of URLs (max 10 processed; the rest returnTOO_MANY_URLS).- Returns
BatchResultwithrequested/succeeded/failedcounts and a per-URLresultslist (each a result or a typed error).
{ "requested": 2, "succeeded": 1, "failed": 1,
"results": [
{ "url": "https://example.com/", "title": "Example Domain", "word_count": 17, ... },
{ "url": "http://127.0.0.1/", "error": { "code": "BLOCKED_HOST", "message": "...", "retryable": false } }
] }
get_stats()
Return a lightweight operational snapshot — uptime, requests served, cache hit/miss + hit rate, and current config. No sensitive data.
{ "version": "0.1.0", "uptime_seconds": 124.3, "requests_served": 6,
"cache_hits": 2, "cache_misses": 1, "cache_hit_rate": 0.6667,
"rate_limit_rps": 5.0, "cache_ttl_seconds": 900 }
Configuration
All settings are environment variables prefixed READABLE_MCP_ (or a .env file — see .env.example). No secrets are required.
| Variable | Default | Description |
|---|---|---|
READABLE_MCP_RATE_LIMIT_RPS |
5 |
Sustained outbound requests per second |
READABLE_MCP_RATE_LIMIT_BURST |
10 |
Token-bucket capacity (max burst) |
READABLE_MCP_MAX_RETRIES |
3 |
Total attempts per fetch (incl. the first) |
READABLE_MCP_RETRY_BASE_DELAY |
0.5 |
Base backoff delay (seconds) |
READABLE_MCP_CONNECT_TIMEOUT |
5 |
Connection-establishment timeout (s) |
READABLE_MCP_READ_TIMEOUT |
15 |
Socket read timeout (s) |
READABLE_MCP_TOTAL_TIMEOUT |
20 |
Hard ceiling for one request (s) |
READABLE_MCP_CACHE_TTL |
900 |
Cache entry lifetime (s) |
READABLE_MCP_CACHE_MAXSIZE |
512 |
Max cached entries |
READABLE_MCP_MAX_BATCH_URLS |
10 |
Max URLs per extract_batch call |
READABLE_MCP_USER_AGENT |
readable-mcp/0.1 (+…) |
Outbound User-Agent |
READABLE_MCP_LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING / ERROR |
Testing
uv run pytest # 49 tests, fully offline (network mocked with respx)
uv run ruff check . # lint
uv run ruff format . # format
Tests cover the production paths directly: SSRF/validation cases, token-bucket timing under concurrency, cache hit/miss/expiry, retry-on-429/503 + Retry-After + give-up + no-retry-on-404, extraction against saved HTML fixtures, and the tool happy/error/partial-success paths.
Design decisions
trafilaturafor extraction — best-in-class boilerplate removal with built-in title/author/date detection and native Markdown output;markdownifyis the fallback when no main body is detected, so the caller still gets usable content rather than an error.- Resolve-then-check SSRF — checking the literal host is insufficient; we resolve DNS and validate every returned IP so a public hostname can't tunnel to private space. Literal-IP hosts skip DNS and are checked directly.
- In-memory TTL cache (not Redis) — an MCP server is a single local process per client; an in-process
TTLCacheunder anasyncio.Lockgives the hit-rate win with zero external dependencies. Swappable behind thecache.pyboundary if a shared cache is ever needed. - Errors as values, not exceptions — tools return typed
ExtractionErrors so the model can branch oncode/retryableand a batch can carry mixed success/failure. The server is designed to never crash on bad input. - Shared rate limiter + concurrency cap for batches — the token bucket bounds throughput while a semaphore bounds simultaneous sockets, so a 10-URL batch is both polite and bounded.
max_retries= total attempts — named for the common env-var convention, but semantically the attempt ceiling (default 3 ⇒ 2 retries). Documented here to avoid the off-by-one ambiguity.- Unknown
output_format— returned as a typedEXTRACTION_FAILEDerror with a clear message rather than silently coercing, so callers learn about the mistake.
License
MIT © Dan Tomescu. See LICENSE.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.