Safe, policy-controlled web access for AI agents (MCP server and CLI)
Overview
A Rust tool that accesses websites over HTTP GET/HEAD/POST/PUT/PATCH/DELETE under a security policy, usable as an (for Claude Code, Claude Desktop and other MCP clients) or as a . This installs the prebuilt binary from the latest release (Apple Silicon), so no Rust toolchain or access to crates.io is needed. An example config is then in $(brew --prefix)/share/surfshield/surfshield.example.toml. To update, run brew upgrade surfshield. The result consists of a JSON block with metadata (status, final URL, redirects, response headers, truncation) and, except with save_to, a second block with the body. In markdown mode the body is wrapped in >> markers; in raw mode it is unchanged. , , , : HTTP POST, PUT, PATCH and DELETE. These tools change state on the remote site (forms, API calls, deletions). They are separate tools, so you can choose per method in your MCP client whether to ask for confirmation. At most one of body, json and form.
README
surfshield: controlled web access for AI agents
A Rust tool that accesses websites over HTTP GET/HEAD/POST/PUT/PATCH/DELETE under a security policy, usable as an MCP server (for Claude Code, Claude Desktop and other MCP clients) or as a CLI.
Install
With Homebrew:
brew install dnovitski/tap/surfshield
This installs the prebuilt binary from the latest release
(Apple Silicon), so no Rust toolchain or access to crates.io is needed. An example config is then in
$(brew --prefix)/share/surfshield/surfshield.example.toml. To update, run brew upgrade surfshield.
Or from source:
cargo install --path .
Use as an MCP server
claude mcp add surfshield -- surfshield serve
With your own config:
claude mcp add surfshield -- surfshield serve --config /path/to/surfshield.toml
Tools
fetch_url: HTTP GET.
| Parameter | Description |
|---|---|
url |
http(s) URL |
format |
markdown (default): HTML → Markdown, JSON pretty-printed. raw: the body exactly as received (base64 if it is not valid UTF-8) |
headers |
Extra request headers, e.g. {"Accept": "application/json"} |
max_chars |
Max. number of body characters (capped by max_output_chars) |
save_to |
Save the exact body to this path, relative to download_dir. Only metadata is returned (path, bytes, SHA-256) |
overwrite |
Replace an existing file at save_to |
The result consists of a JSON block with metadata (status, final URL, redirects, response headers,
truncation) and, except with save_to, a second block with the body. In markdown mode the body is
wrapped in >> markers; in raw mode it is unchanged.
post_url, put_url, patch_url, delete_url: HTTP POST, PUT, PATCH and DELETE.
These tools change state on the remote site (forms, API calls, deletions). They are separate tools,
so you can choose per method in your MCP client whether to ask for confirmation.
| Parameter | Description |
|---|---|
url |
http(s) URL |
body |
Raw body, sent exactly (default Content-Type: text/plain; charset=utf-8) |
json |
JSON body, sent with Content-Type: application/json |
form |
Form fields, URL-encoded with Content-Type: application/x-www-form-urlencoded |
headers, format, max_chars, save_to, overwrite |
As with fetch_url |
At most one of body, json and form. Without a body, POST, PUT and PATCH send an empty body
(Content-Length: 0) and DELETE sends none. A Content-Type in headers overrides the default. The result has the same shape as with fetch_url.
head_url: HTTP HEAD; returns status, final URL and response headers.
Use as a CLI
surfshield get https://example.com
surfshield get --raw https://example.com > page.html
surfshield get -o page.html https://example.com
surfshield get -i -H "Accept: application/json" https://api.example.com/data
surfshield head https://example.com
surfshield post --json '{"name": "test"}' https://api.example.com/items
surfshield post --field q=searchterm --field lang=en https://example.com/search
surfshield post -d @body.xml -H "Content-Type: application/xml" https://example.com/api
surfshield put --json @item.json https://api.example.com/items/7
surfshield patch --json '{"status": "done"}' https://api.example.com/items/7
surfshield delete https://api.example.com/items/7
--rawwrites the exact bytes to stdout.-o FILEwrites the exact bytes to any path (like curl); thedownload_dirrestriction only applies to MCPsave_to.post,put,patchanddeletetake at most one body:-d/--data(sent exactly,@FILEor@-for stdin; defaultContent-Type: application/x-www-form-urlencodedlike curl),--json(also@FILE/@-, is validated) or--field NAME=VALUE(repeatable, URL-encoded).--raw,-o,-H,-iand--max-charswork as withget.-ishows the status and response headers. Messages go to stderr.--proxy URLsends the request through a proxy and overridesproxyfrom the config (see below).- The exit code is 1 on a policy or network error. HTTP 4xx/5xx counts as success.
Proxy
surfshield --proxy socks5h://127.0.0.1:1082 get https://ifconfig.me
Or permanently in the config, under [http]: proxy = "socks5h://127.0.0.1:1082". Supported are
socks5://, socks5h:// and http://, always with an IP address and a port (no hostname,
so no DNS is involved around the proxy itself). The proxy may be on loopback or the LAN; it is
explicitly configured and therefore not subject to the IP check.
| Scheme | DNS resolution | What the policy still checks |
|---|---|---|
socks5:// |
local, via the policy resolver | everything, including the IP check after DNS; the proxy gets the checked IP |
socks5h:// |
at the proxy | scheme, denylist, ports and IP literals in the URL |
http:// |
at the proxy | scheme, denylist, ports and IP literals in the URL |
With socks5h:// and http:// the proxy decides where a hostname goes; if a name points to an
internal address behind the proxy, the policy cannot stop that. Use socks5:// if the
full IP check must keep applying, or a proxy that refuses internal addresses itself.
Security
- Denylist: everything is allowed except
denied_domains(*.example.comalso blocks example.com itself) anddenied_ports. - No internal network: loopback, private (RFC1918), link-local (incl. cloud metadata
169.254.169.254), CGNAT, multicast, IPv6 ULA and IPv4-in-IPv6 variants are blocked. The check happens after DNS resolution and the connection goes to exactly the checked addresses (no DNS rebinding). Can be disabled withallow_private_ips = truefor local development. - Redirects are followed manually (max.
max_redirects); every hop goes through the policy again. On a redirect to another origin,Authorization,CookieandProxy-Authorizationare removed. - Redirects of POST/PUT/PATCH/DELETE follow browser rules: after a 303 it becomes a GET without body, as after a 301/302 on a POST. Otherwise method and body are kept, but only within the same origin: a state-changing request is never repeated on another origin (the body may contain credentials, and the effect was meant for this site). That gives an error with the target URL; if you want that, send the request there explicitly yourself.
- Methods:
allowed_methods(default all six). Restrict to["GET", "HEAD"]for read-only access, or remove justDELETE; the other tools then return a policy error. - Headers: the AI may send headers, except
denied_request_headers(default incl.Host,Content-Length,Transfer-Encoding,Proxy-*). CR/LF injection is refused; max. 50 headers and 8 KB. - Limits: max. body (10 MB), max. request body (1 MB), max. output (100,000 characters), connect timeout (5s) and total timeout (30s).
- Only
http/https, no credentials in the URL, no cookies, no system proxy (only an explicitly configured proxy, see Proxy). save_tostays withindownload_dir: no absolute paths or.., symlinks pointing outside are refused, and a truncated body is never saved.
Configuration
See surfshield.example.toml. Search order: --config, ./surfshield.toml,
~/.config/surfshield/config.toml; without a file the defaults apply.
Logging goes to stderr; set the level with SURFSHIELD_LOG=info.
Development
cargo test
The tests only run against a local mock server (wiremock); no external sites are accessed.
Install
This server does not publish a one-line install command.
Open the repository installation guide