Test an in-development MCP server
Give a locally running MCP server a public, authenticated URL so remote AI providers (Claude, OpenAI, others) can connect to it while you develop - with each provider on its own token and your server never publicly exposed.
Before you start
Auth is required (ngrok-setup). This job uses a cloud endpoint + internal endpoint and a Traffic Policy with per-provider auth + forward-internal - both from ngrok-engine. A working reference implementation ships in references/: http-transport.ts and traffic-policy.yaml.
Architecture
MCP client (Claude/OpenAI) --Bearer token--> Cloud Endpoint (public, runs the policy)
--forward-internal--> Agent Endpoint (bound internal, e.g. https://mcp.internal)
--HTTP--> your MCP server (streamable HTTP transport)
Cloud endpoint (not agent) is required here: providers connect on their own schedule, so the public URL must persist independently of your dev process.
Steps
Give your MCP server an HTTP transport. MCP dev servers are usually stdio; providers need streamable HTTP. Drop in
references/http-transport.tsand call it alongside your existing stdio path:await runHttp(buildServer, port); // POST /mcp, bound to 127.0.0.1It needs
expressand@modelcontextprotocol/sdk. It binds loopback by default, so the process is reachable only through the local ngrok agent - pass{ host }to change that. stdio keeps working for local clients.Run the server as an internal endpoint. The
.internalURL is what makes it internal - no extra binding flag:ngrok http <port> --url https://mcp.internal --host-header=rewriteThe--host-header=rewritematters: traffic arriving viaforward-internalcarriesHost: mcp.internal, which the MCP SDK's DNS-rebinding protection rejects by default.Create a vault + one secret per provider. These are tokens you generate (not the provider's API key), so you can tell providers apart:
ngrok api vaults create --name "mcp-callers" ngrok api secrets create --name "claude-key" --value "$(openssl rand -hex 32)" --vault-id "$VAULT_ID"Create a cloud endpoint with the policy (
references/traffic-policy.yaml). Its shape is security-critical, so use it as-is rather than re-deriving it: one rule per provider that matches that provider's bearer token, tags the caller, andforward-internals - which is terminating - followed by an unconditional catch-all that returns 401. Anything without a valid token matches no provider rule and falls through to the 401.Do not restructure it so the rejection tests the
x-mcp-callertag instead. That header is added by the policy but is also a request header, so a client can send it and skip the check entirely.Attach via whichever surface fits (see
ngrok-surfaces: Terraform or the operator for a durable setup,ngrok apifrom the CLI for a quick one).Point each provider at
https://<your-cloud-endpoint>/mcpwith its bearer token as a custom header. Add a provider later by adding a secret and copying its policy rule.
Gotchas
- New server instance per request. MCP's stateless HTTP pattern needs a fresh server object per request so concurrent providers don't share session state.
references/http-transport.tsfollows this. - Caller attribution is advisory.
x-mcp-callertells your server which provider called, but a client holding any valid token can also send that header itself. Trust it for logging, not for authorization. - The
/mcppath. Providers expect the MCP endpoint at a path (commonly/mcp); make sure the transport and the URL you hand out agree. - host-header rewrite (step 2) - the single most common reason a forwarded MCP request 400s.
Notes for agents
- This is distinct from
expose-localhost: it needs per-provider auth and a persistent cloud endpoint, not a plain tunnel. - Don't invent the transport - use the bundled
http-transport.tsand adapt the user'sbuildServer.