A Website Can Read Your Local MCP Server, and Here's How to Stop It
You add a local MCP to your app, bind it to 127.0.0.1, and move on. Then a page you open in Safari reads it, lists its tools, and calls them. There are no sketchy downloads or malware, you just visited a page. I ran this against a real FastMCP server and it read the config back to me, fake Stripe key and database URL included.
The whole thing is about 150 lines of Python and one HTML file, and you can find the code on GitHub linked below in the article.
The technique is DNS rebinding, which has been kicking around since the '90s. The reason it is worth your attention now is that a lot of us are building local MCP servers that bind 127.0.0.1, have no auth, and assume that binding loopback is the same as being private. It isn't.
"It's only on localhost" is the assumption that bites you
Binding to 127.0.0.1 keeps other machines out, and that part is true. But your browser runs on your machine, and every site you open can make requests through it. What normally stops evil.example from reading a response off your localhost is the same-origin policy, and DNS rebinding makes that policy look the other way.
The trick, in five steps
The attacker controls the DNS for a domain, say demo.rebind.example, and gives it a very short memory.
- Your browser opens
http://demo.rebind.example:4040/. The attacker's DNS answers with the attacker's own server IP, so the page loads from the attacker. - The moment that page is served, the attacker flips the DNS record to point at
127.0.0.1. - The page keeps calling
fetch("http://demo.rebind.example:4040/mcp")in a loop. At first the browser reuses its cached DNS answer, so these requests still go to the attacker. After about a minute the cache expires and the name is resolved again. - The name now resolves to
127.0.0.1, so the next request lands on the MCP server on your machine. Scheme, host and port still match the page, so the browser treats it as same origin and applies no CORS checks. - Because it is same origin, the page reads the full response. It runs the MCP handshake, lists the tools, and calls one.
The browser thinks it is talking to demo.rebind.example the entire time, which is exactly why same-origin policy stays happy, while the packets in step 4 go to your loopback.
See it live
The demo is a FastMCP server with no auth on 127.0.0.1:4040, plus a small attacker setup: a DNS server that flips the record to 127.0.0.1, and a Flask app that serves the page. The page runs the MCP handshake and prints what it read:
{
"server": { "name": "acme-api", "version": "1.0.0" },
"tools": ["get_config", "list_users"],
"get_config": {
"app": "acme-api",
"env": "production",
"stripe_secret": "sk_test_DEMO",
"database_url": "postgres://acme:hunter2@localhost/acme"
}
}
That is a public web page printing the configuration of a server bound to your loopback.
The modern wall, and the gap in it
Chrome and Firefox now restrict public pages from reaching loopback (Private Network Access, evolving into Local Network Access). Depending on the browser and version, the request is either blocked outright or held behind a permission prompt the user has to accept. Against those browsers today, this attack fails unless the user clicks "Allow", and that is a good defense.
Safari has not shipped that check yet. I re-tested it on the latest Safari (27) in September 2026 and a public page still reaches loopback, so in Safari the attack works right now. WebKit has Local Network Access in progress, so this will likely change, but don't count on it.
The bigger point is the one that survives every browser update: a non-browser client has neither a same-origin policy nor Local Network Access. Think of an AI agent with a fetch tool that refuses localhost URLs. The check looks at the URL, sees demo.rebind.example, lets it through, and the DNS lookup that happens afterwards sends the request straight to your loopback. The browser was never your security boundary.
This is FastMCP's default setup
I built the victim with the standalone fastmcp package (4.0.10 at the time of writing) because it is one of the most common ways to stand up an MCP server. It actually ships a Host and Origin guard, it is just off by default: I sent an initialize request carrying an attacker Host and Origin, and the server answered normally, handed back a session, and served every tool call. So the exposure here is what you get out of the box, before anyone has misconfigured anything.
The fix
If you are on FastMCP, it is one flag. Set host_origin_protection=True and the server rejects requests with a foreign Host:
mcp.run(transport="http", host="127.0.0.1", port=4040, host_origin_protection=True)
The principle underneath works on any stack: reject any request whose Host header is not a loopback name. A rebinding request always arrives carrying the attacker's hostname in Host, never 127.0.0.1, so this one check stops every client that was tricked into following a rebinding URL, browser or not:
from urllib.parse import urlsplit
host = urlsplit("//" + request.headers.get("host", "")).hostname
if host not in ("127.0.0.1", "localhost", "::1"):
return forbidden()
A few things to pair with it:
- Run the check on every method and path, including the SSE stream, not only the main POST.
- Validate the
Originheader too when it is present. - Require a local token. A malicious process already running on your machine can set
Host: 127.0.0.1itself, and the token is what stops that one. - Keep binding
127.0.0.1and add the Host check on top of it.
The MCP Streamable HTTP spec already says servers MUST validate the Origin header to prevent exactly this. The reason it gets skipped is that "it's only local" talks people out of reading the security section.
How we handle it in our own tools
At Software Mansion we build Argent, a toolkit for driving iOS simulators, emulators and Electron apps from a coding agent, so this is a problem we have to get right. Argent's MCP transport is stdio, so the agent talks to it over a pipe that only the parent process can reach. The local tool-server it runs for previews and device control is an Express app on 127.0.0.1, and it puts a Host allowlist in front of everything: a request whose Host header is not a loopback name (or a bind host you explicitly opted into) is rejected before it reaches the auth gate. Its tool API also requires a token, so a local process still has to prove it belongs there.
Check your own server
Send an initialize request with a Host header that isn't yours (swap in your port):
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \
-H 'Host: evil.example' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}' \
http://127.0.0.1:4040/mcp
If this returns 200, a rebinding page can reach your server.
Local does not mean private. Add the check, keep the token, and your MCP stays yours.
The full demo is on GitHub: https://github.com/dazakdev/mcp-dns-rebinding-demo

