tomydiaz
All posts

MCP Server Hands-On · Part 3 of 5

5 min read

Taking the MCP Server Off My Laptop and Plugging Claude Into It

Binding to 0.0.0.0 switched off a protection without saying so, a health check could never pass, and what actually happens when you paste a URL into Claude.

After part two, the events API was deployed and closed, with two anonymous endpoints left open for reading published events. The MCP server was still on my laptop. This post covers moving it out and connecting to it from Claude Code and from the browser, the way the rest of the team eventually will.

The protection that turned itself off

Locally the server listened on 127.0.0.1. In the cloud it has to listen on 0.0.0.0, and that change does more than it looks like.

The MCP SDK protects servers against DNS rebinding, an attack where a malicious web page gets your browser to send requests to a server on your machine by pointing its own hostname at a local address. The defence is to check the Host header. When I read the SDK’s source, the automatic check only applies when the server binds to a localhost address. Bind to 0.0.0.0 without listing hosts, and the only protection left is a warning printed at startup.

So the server gained an ALLOWED_HOSTS setting with the public hostname. I tested it by forging the header: the real hostname got 200, anything else got 403.

A health check that could never pass

The first deploy went fine. Later I moved the server to Fly.io, and there the deploy failed: the health checks timed out. The logs showed the Node process starting and printing that it was listening. The platform insisted the machine was unhealthy, and the public URL answered 503.

The cause was the protection from the previous section. The SDK’s helper applies the Host check to every route, /health included. Fly’s health checker calls the machine directly on its private address, so the Host header it sends is an internal IP. My server rejected its own health check, the platform never marked the machine as healthy, and its proxy refused to send it any traffic.

The first place I deployed to never probed /health unless configured to, so the bug had been there from the start with nothing calling it. The fix was to build the Express app myself and apply the host check to /mcp only. /health returns a static status and exposes nothing, so it does not need it.

There was a red herring in the logs too. The very first health check fired one second before Node started listening and recorded connection refused. That line was true, and it had nothing to do with the real problem.

The fix also changed a default, on purpose. If the server is started on 0.0.0.0 without ALLOWED_HOSTS, /mcp now rejects anything that is not localhost, where the SDK used to accept everything. A missing setting shows up as a 403 instead of an open server.

Making sure it was really remote

I did not trust a test that could still be quietly hitting my laptop, so I made that impossible. I killed the local server, removed the local registration from Claude Code, and added the deployed URL at user scope. claude mcp list showed it connected, and since that check does a real handshake, it was connecting to the public server. A new Code session, the same question as before, and the events came back with nothing of mine running except the client.

Pasting the URL into Claude in the browser

This was the test I cared about most, because it is exactly what a teammate will do: open Claude in the browser, go to connectors, add a custom one, and paste a URL.

The dialog asks two things. The first is authentication, with three choices: sign in right away, sign in only when the server asks, or no sign-in. The third was already marked “Detected”. Claude probes the server before you choose anything, and it had already worked out that mine asks for no credentials.

The second is the OAuth client, again with three options: use Claude’s published identity, register automatically, or bring your own client ID. Without a login none of those apply. They become the central decision in part four.

It connected on the first try. This is also where the local confusion from part one finally made sense. The browser’s connection to an MCP server starts from Anthropic’s servers, not from my browser. That makes the browser a stricter test than Claude Code: it only works if the server is reachable from the public internet, which is what a teammate on a different network needs anyway.

PROD, which was test

I named the connector “Events MCP PROD”. It was pointing at the test API.

It sounds like a typo, and it shows how the pieces relate. The connector stores a URL and nothing else. Which API, and therefore which database, answers is decided by the server’s own configuration. Switching the server from test to production meant changing one environment variable and redeploying. The connector stayed exactly as it was, with no reinstall.

To check which database I was actually talking to, I used the event count. Test had 167 published events and production had 170. Asking for the events before and after the change, and watching the number move, was quicker than any log.

The end state

Claude (browser and Code) → MCP server → events API (Azure Functions) → MongoDB

A teammate installs it by pasting one URL. Nothing runs on anyone’s laptop, and the only tool is read only.

The URL is public and unauthenticated, which is fine while the only thing behind it is a list of events that are already public. The next tools on the list create and edit events, and an unauthenticated URL cannot be allowed to do that. Part four is the login design.