Expose a local MCP server
AI clients call tools on remote MCP servers over HTTPS. Hosted ones — custom connectors in Claude, connectors in ChatGPT — reach your
server from their own infrastructure, so localhost is out of their reach. devgate gives the server on your machine
a public HTTPS URL in one command, and you can try every change from a real client without deploying it.
Before you start
You need an MCP server that speaks HTTP — Streamable HTTP, or the older SSE transport — on a local port, say
http://localhost:8000/mcp. A server that only speaks stdio has no port to expose; run it behind an HTTP
transport first. And you need the devgate agent with your authtoken added (Quickstart).
Put it online
$ devgate http 8000 --url https:// https:// with no host means your dev domain: every account has one, and its name never changes, so a client
you register it with keeps working across restarts. The agent prints the URL; your MCP endpoint is that URL plus the server's path,
e.g. https://brave-otter-moon.devgate-free.run/mcp. Without --url you get a random
address that changes on every run.
Connect a client
- Claude and ChatGPT — add the URL as a custom connector (the menu names vary between versions). These clients connect from their own servers, which is why the URL has to be public.
- Claude Code — add it as an HTTP server:
$ claude mcp add --transport http my-tools https://<your-dev-domain>/mcp - Anything else that speaks Streamable HTTP — Cursor, the MCP Inspector, your own code — takes the same URL.
Every call then shows up in the dashboard's Traffic Inspector; turn on full capture to see the JSON-RPC bodies, which is the quickest way to find out what a client actually sent.
Keep it private
A public URL is reachable by anyone who has it, and an MCP server is often a direct line into your data. If your client can send a
header — Claude Code, Cursor and the MCP Inspector can — require a token at the edge, so requests without it never reach your
machine. Make one with openssl rand -hex 32, put it in the policy, and keep the file out of git:
on_http_request:
- expressions:
- "!('Bearer <your-token>' in getReqHeader('authorization'))"
actions:
- type: custom-response
config:
status_code: 401
headers:
content-type: application/json
www-authenticate: Bearer
body: '{"error":"unauthorized"}' $ devgate http 8000 --url https:// --traffic-policy-file mcp-policy.yml Then give the client the same header:
$ claude mcp add --transport http my-tools https://<your-dev-domain>/mcp --header "Authorization: Bearer <your-token>" custom-response costs no Traffic Policy units, so this works on
every plan. Hosted connectors can't always send a header of your choosing: for them, put authorization (OAuth) in the MCP server
itself, or allow only the provider's published IP ranges with
restrict-ips (a paid action, Hobby and up).
If it doesn't connect
- “Tunnel offline” — the agent isn't running, or isn't connected; start it again and keep it running while you use the client.
- 404 — the path is wrong: the server listens on
/mcp,/sseor its own path, not the root. - 403 or “invalid host” — the server checks the Host header to guard against DNS rebinding, and doesn't know your
devgate hostname. Add the hostname to its allowed hosts, or start with
--host-header=rewriteso it seeslocalhost:8000(more on Host headers). - 401 — the client doesn't send the token, or sends a different one; compare them in the Traffic Inspector.
- 429 — the account went over its per-minute rate limit, usually a client stuck in a retry loop.
Questions people actually ask
Yes. Responses pass through as they are written, so each server-sent event reaches the client at once, not when the stream ends — with full capture on as well. A stream may stay open for up to an hour without traffic.
No. The warning page is only for people opening a page in a browser. An MCP client sends no browser User-Agent and asks for JSON or an event stream, so its requests go straight to your server.
Start the endpoint on your dev domain: devgate http 8000 --url https:// — every account has one, and it never changes. A run without --url gets a new random address each time.