fetch-url-raw
by cinit
README.md
# fetch-url-raw
Short description: a **stateless MCP server** that exposes one tool, `fetch_url_raw` — a lightweight programmable HTTP client for LLM agents. Send arbitrary methods, headers, and bodies; control timeouts, redirects, response size, TLS verification, and DNS overrides. No cookies or session state between calls.
## Requirements
- Python 3.12+
- Network access from the host that runs the server
## Install
```bash
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
```
For tests:
```bash
pip install -e '.[dev]'
```
## Deployment
The server speaks **MCP over stdio**. Deploy it as a local process that your MCP client launches; it does not open a public HTTP port by default.
### 1. Install into a venv (recommended)
```bash
cd /path/to/fetch-url-raw
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
Confirm the entry point (stdio by default; waits on stdin for MCP):
```bash
fetch-url-raw
# Stop with Ctrl+C.
```
Or:
```bash
python -m fetch_url_raw
```
HTTP mode is off by default. To enable it, see section 3 below.
### 2. Wire into an MCP client
Point the client at the venv interpreter (or the `fetch-url-raw` script) so dependencies resolve correctly.
**Claude Desktop** (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"fetch-url-raw": {
"command": "/path/to/fetch-url-raw/.venv/bin/python",
"args": ["-m", "fetch_url_raw"],
"cwd": "/path/to/fetch-url-raw"
}
}
}
```
**Generic MCP host / Cursor-style config:**
```json
{
"mcpServers": {
"fetch-url-raw": {
"command": "/path/to/fetch-url-raw/.venv/bin/fetch-url-raw"
}
}
}
```
**Codex / other stdio MCP runners:** use the same `command` + `args` pattern; no extra env is required for basic use.
### 3. Optional HTTP server mode (not enabled by default)
Default transport is **stdio**. To expose MCP over HTTP instead, pass `--transport` explicitly and set listen address/port:
```bash
# Streamable HTTP (recommended HTTP transport)
fetch-url-raw --transport streamable-http --host 127.0.0.1 --port 8000
# SSE transport
fetch-url-raw --transport sse --host 127.0.0.1 --port 8000
# Bind all interfaces (trusted networks only)
fetch-url-raw --transport streamable-http --host 0.0.0.0 --port 9000 --allow-remote
```
| Flag | Default | Description |
|------|---------|-------------|
| `--transport` | `stdio` | `stdio`, `streamable-http`, or `sse` |
| `--host` | `127.0.0.1` | Listen IP (HTTP transports only) |
| `--port` | `8000` | Listen port (HTTP transports only) |
| `--path` | `/mcp` or `/sse` | Endpoint path (`streamable-http` → `/mcp`, `sse` → `/sse`) |
| `--stateless-http` | off | FastMCP stateless HTTP mode (`streamable-http` only) |
| `--allow-remote` | off | Relax Host/Origin DNS-rebinding checks for non-local clients |
| `--allow-private-network` | off | Allow resolved private/local destination IPs (see below) |
| `--log-level` | `INFO` | Uvicorn/server log level |
Endpoints:
- `streamable-http`: `http://<host>:<port>/mcp` (or custom `--path`)
- `sse`: `http://<host>:<port>/sse` (messages under the default FastMCP message path)
Example MCP client config against a local HTTP server (client-specific; streamable HTTP):
```json
{
"mcpServers": {
"fetch-url-raw": {
"url": "http://127.0.0.1:8000/mcp"
}
}
}
```
> HTTP mode is optional. Prefer stdio for desktop/local agent integrations. Only bind `0.0.0.0` or enable `--allow-remote` on trusted networks; the tool can initiate arbitrary outbound HTTP.
### Private / local destination blocking
By default the server **blocks connections by resolved destination IP** (not by DNS name):
- DNS lookup is always allowed
- After resolve (or `dns_override` / literal IP URL), the TCP destination must not fall in private/local ranges
- Blocked by default: RFC1918 (`10/8`, `172.16/12`, `192.168/16`), loopback, link-local (`169.254/16`, `fe80::/10`), CGNAT (`100.64/10`), IPv6 ULA (`fc00::/7`)
- Always blocked: multicast / unspecified special-use ranges
- IPv4-mapped IPv6 addresses are checked as their IPv4 form
Opt in when you intentionally need LAN/metadata/loopback targets:
```bash
fetch-url-raw --allow-private-network
# with HTTP mode:
fetch-url-raw --transport streamable-http --host 127.0.0.1 --port 8000 --allow-private-network
```
Blocked attempts return:
```json
{
"success": false,
"error": {
"type": "DESTINATION_BLOCKED",
"message": "destination IP 192.168.1.1 is private/local (...)"
}
}
```
### 4. Operational notes
| Topic | Guidance |
|-------|----------|
| State | Stateless — safe to restart anytime; no DB or disk cache |
| Network | Outbound HTTP/HTTPS only; needs reachability to targets you fetch |
| Security | Tool can hit arbitrary URLs — run only for trusted clients; consider host firewall / network policy |
| Resources | Returned body is capped (`max_response_bytes`, default 1 MiB); up to 16 MiB may be buffered to decode large text |
| Proxies | System proxy env is ignored (`trust_env=False`) for predictable behavior |
| Logs | Server logs go to stderr; keep stdin/stdout for MCP framing in stdio mode |
| HTTP listen | Not started unless `--transport streamable-http` or `--transport sse` is set |
### 5. Optional: install from wheel
```bash
pip install dist/fetch_url_raw-0.1.0-py3-none-any.whl
fetch-url-raw
```
## Usage
Once the MCP server is connected, call the `fetch_url_raw` tool from the client.
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `url` | string | **required** | Absolute `http` or `https` URL |
| `method` | string | `GET` | HTTP method (normalized to uppercase) |
| `headers` | object | — | Request headers (`string` → `string`) |
| `body` | string \| object \| array \| number \| bool | — | Request body: raw string as-is, or JSON value (object/array/number/bool) which is serialized and gets `Content-Type: application/json` when unset |
| `timeout` | number | `30` | Timeout in seconds |
| `follow_redirect` | bool | `true` | Follow redirects |
| `max_response_bytes` | int | `1048576` | Max body bytes returned to the LLM. Internally buffers up to 16 MiB so large text can still be decoded, then truncates the returned `body`/`body_base64` |
| `dns_override` | object | — | Hostname → IP map (like `curl --resolve`) |
| `verify_tls` | bool | `true` | Verify TLS certificates |
| `include_tls` | bool | `false` | Include TLS metadata + peer cert(s) in the result (HTTPS) |
| `tls_only` | bool | `false` | Handshake only (no HTTP). Requires `include_tls=true` and `https` |
### Example tool calls
**GET**
```json
{
"url": "https://example.com/"
}
```
**POST JSON** (LLM-friendly: pass a JSON object directly; string body still works)
```json
{
"url": "https://httpbin.org/post",
"method": "POST",
"headers": {
"Authorization": "Bearer token"
},
"body": {"hello": "world"},
"timeout": 15
}
```
Raw string body (no auto Content-Type):
```json
{
"url": "https://httpbin.org/post",
"method": "POST",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "hello=world&a=1"
}
```
**No redirects + small body cap**
```json
{
"url": "https://example.com/redirect",
"follow_redirect": false,
"max_response_bytes": 4096
}
```
**DNS override** (connect to `1.2.3.4` while keeping Host/SNI as `api.example.com`)
```json
{
"url": "https://api.example.com/health",
"dns_override": {
"api.example.com": "1.2.3.4"
},
"verify_tls": true
}
```
**Inspect TLS cert only** (no HTTP request)
```json
{
"url": "https://example.com/",
"include_tls": true,
"tls_only": true
}
```
**Fetch and include TLS info**
```json
{
"url": "https://example.com/",
"include_tls": true,
"max_response_bytes": 4096
}
```
### Success response
```json
{
"success": true,
"status": 200,
"reason": "OK",
"http_version": "HTTP/1.1",
"headers": { "...": "..." },
"body": "...",
"body_json": null,
"body_base64": null,
"content_type": "text/html",
"encoding": "utf-8",
"elapsed_ms": 123,
"redirected": false,
"final_url": "https://example.com",
"truncated": false,
"received_bytes": 12345,
"content_length": 12345
}
```
- Text-like content types (`text/*`, `application/json`, etc.) fill `body` (string still kept).
- `body_json` is set **only** when the body is valid JSON (object/array/etc.); otherwise `null`.
- Other non-text types set `body` to `null` and put Base64 data in `body_base64`.
- Internally the client may buffer up to **16 MiB** so large text (e.g. JS bundles) can be decoded as text even when `max_response_bytes` is smaller. The tool result is then truncated to `max_response_bytes`.
- If the returned body is truncated, `truncated` is `true` and only the first N bytes (or text whose UTF-8 size is N) are returned.
- `received_bytes` is how many body bytes were actually returned to the LLM (after truncation).
- Optional `tls` object (when `include_tls` or on many `TLS_ERROR`s): version, cipher, ALPN, SNI, peer IP, leaf cert PEM/fingerprint/SAN/dates, and chain.
- `content_length` is the full response body size when known: from the `Content-Length` header if present, otherwise the full size if the body fit in the 16 MiB prefetch buffer, otherwise `null` if the stream was cut at the prefetch ceiling without a header. Use this so agents know the real size (e.g. 1 MiB JS) while reading only the first 64 KiB of text.
### Error response
Failures return a structured object instead of raising:
```json
{
"success": false,
"error": {
"type": "TIMEOUT",
"message": "Operation timed out"
}
}
```
| `error.type` | Meaning |
|--------------|---------|
| `INVALID_URL` | Missing/unsupported scheme or host |
| `INVALID_PARAMETER` | Bad method, headers, timeout, etc. |
| `DNS_ERROR` | Hostname could not be resolved |
| `TIMEOUT` | Connect/read/write/pool timed out (message says which phase) |
| `TLS_ERROR` | Certificate or TLS failure (message distinguishes expired, self-signed/untrusted, hostname/SAN mismatch, incomplete chain, outdated/unsupported protocol, malformed TLS) |
| `CONNECT_ERROR` | TCP/connect failure (message distinguishes refused, reset/RST, network/host unreachable, broken pipe, etc.) |
| `DESTINATION_BLOCKED` | Resolved destination IP denied by private-network policy |
| `PROTOCOL_ERROR` | HTTP protocol / too many redirects |
| `HTTP_ERROR` / `ERROR` | Other HTTP or unexpected failure |
Example `message` values:
- `TIMEOUT`: `Connection timed out while establishing TCP/TLS`, `Timed out while reading the response`
- `CONNECT_ERROR`: `Connection refused (no service listening or port closed)`, `Connection reset by peer (TCP RST during connect or request)`, `Network is unreachable (no route to destination network)`, `No route to host (destination host unreachable)`
- `TLS_ERROR`: `TLS certificate has expired`, `TLS certificate is self-signed and not trusted (...)`, `TLS certificate hostname mismatch: ... (wrong CNAME/SAN)`, `TLS certificate chain incomplete or untrusted: unable to get local issuer certificate (...)`, `TLS protocol version mismatch or unsupported/outdated TLS (...)`, `TLS handshake failed: malformed or unexpected TLS data (...)`
## Features
- Methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `TRACE`
- Custom headers and body (raw string or JSON value for LLM-friendly POSTs)
- Timeout, redirect control, response size limit
- DNS override (SNI and Host header preserved)
- TLS verification toggle
- Stateless: no cookies, session cache, or filesystem writes
- Default block of private/local destination IPs (post-resolve); opt-in with `--allow-private-network`
- Structured errors suitable for LLM tool loops
## Development
```bash
pip install -e '.[dev]'
pytest
FETCH_URL_RAW_LIVE=1 pytest tests/test_live.py # optional live network tests
```
## Design
See [design.md](design.md) for architecture, DNS override details, body decoding rules, and security considerations.
## License
MIT
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues