Readable
БесплатноНе проверенConverts any URL into clean, LLM-ready Markdown, text, or HTML with production-grade features like SSRF protection, rate limiting, retries, caching, and structu
Описание
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
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.
Установка Readable
У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.
▸ github.com/tommypj/readable-mcpFAQ
Readable MCP бесплатный?
Да, Readable MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Readable?
Нет, Readable работает без API-ключей и переменных окружения.
Readable — hosted или self-hosted?
Доступен hosted-вариант: Unyly запускает сервер в облаке, локальная установка не обязательна.
Как установить Readable в Claude Desktop, Claude Code или Cursor?
Открой Readable на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
Fetch
Web content fetching and conversion for efficient LLM usage.
AWS KB Retrieval
Retrieval from AWS Knowledge Base using Bedrock Agent Runtime.
автор: modelcontextprotocolSpring AI MCP Server
Provides auto-configuration for setting up an MCP server in Spring Boot applications.
llm-analysis-assistant
A very streamlined mcp client that supports calling and monitoring stdio/sse/streamableHttp, and can also view request responses through the /logs page. It also
автор: xuzexin-hzCompare Readable with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории ai
