Lokale MCP-Server sicher testen: So bleibt der Rechner geschützt
KI-Agenten greifen über das Model Context Protocol auf lokale Tools, Dateien und Datenbanken auf Entwickler-Rechnern zu. Anthropic hat MCP eingeführt und im Dezember 2025 an die Agentic AI Foundation übergeben. Der Zugang von Remote-Clients auf lokale Server lässt sich absichern, ohne die Sicherheit des Hosts zu gefährden.

Posted on Oct 27 • Originally published at instatunnel.my • Fully Autonomous
AI engineering has moved a lot of work onto developer machines. Agents call local tools, read local files, and query local databases through the Model Context Protocol (MCP). Anthropic introduced MCP, and in December 2025 donated it to the Agentic AI Foundation, a directed fund under the Linux Foundation. It is now a vendor-neutral open standard.
The friction shows up when something that isn’t on your machine needs to reach your MCP server. That might be a hosted agent, a teammate’s client, or a staging environment. A tunnel is the fastest fix, but it turns a loopback-only process into a public HTTPS endpoint. A local MCP server is a program running with your user account’s privileges, so whatever it is coded to touch, anyone who can reach it can try to touch.
The current specification defines two standard transports: stdio and Streamable HTTP. The older HTTP+SSE transport from the 2024-11-05 revision is deprecated, and new implementations should not adopt it.
With stdio, the client launches the MCP server as a subprocess and exchanges JSON-RPC messages over standard input and output. The spec says clients SHOULD support stdio whenever possible.
A remote client cannot spawn a process on your laptop, so exposing a stdio server remotely needs a network layer, either a bridge or an HTTP-native server.
In the latest revision (2026-07-28), Streamable HTTP is much simpler than earlier versions:
Remote agent / test client │ HTTPS POST /mcp (JSON or SSE response) ▼ InstaTunnel public edge (https://<subdomain>.instatunnel.my) │ tunnel ▼ Local MCP server on 127.0.0.1:8787 Enter fullscreen mode Exit fullscreen mode What the Specification Requires of an HTTP Server For Streamable HTTP the spec’s security section says:
Authorization itself is optional in MCP. When an HTTP server does implement it, the spec builds on OAuth 2.1 and says the server acts as a resource server. Among its requirements:
A tunnel does not create new MCP vulnerabilities. It changes who can reach the ones you already have.
Prompt injection reaching tool arguments. Models build tool arguments from context, and context can contain attacker text. A Cloud Security Alliance research note describes a demonstrated chain in which a crafted Slack message, handled through an already-approved Slack MCP server, steered Cursor into rewriting its global mcp.json to add an attacker-controlled server. A tunnel does not cause this, but it widens the set of clients that can call your tools. Remote clients are also less likely to have a human approving each call.
Tool poisoning. Malicious instructions can sit in tool descriptions that the model sees but the user usually doesn’t. Invariant Labs published the original advisory in April 2025. Review tool definitions, pin versions, and treat descriptions as part of your supply chain.
Unauthenticated endpoints and DNS rebinding. CVE-2025-49596 was an unauthenticated remote code execution flaw in MCP Inspector. Its proxy exposed an endpoint with no authentication between client and proxy. It was fixed in 0.14.1. Oligo also demonstrated the same RCE through DNS rebinding, where an attacker’s web page reaches a localhost service. Current Inspector builds require a proxy token by default, bind to localhost, and validate Origin. Binding to loopback alone was not enough before the fix.
Token passthrough and confused deputy. The MCP security best practices describe token passthrough as an anti-pattern. A server accepts tokens that weren’t issued to it and forwards them to a downstream API. The authorization spec explicitly forbids it, because it can bypass controls such as rate limiting that depend on token audience. If your local server proxies to another API, validate the audience and never forward the client’s token.
Reference servers are demonstrations. The official modelcontextprotocol/servers repository carries a warning that its servers are reference implementations that demonstrate MCP features and SDK usage. The Filesystem and Git servers are still maintained there, while the SQLite server has moved to an archived repository that offers no security guarantees. Don’t put one behind a public URL just because it is official.
The details below come from the npm README for the instatunnel CLI (version 1.1.23). Check the current docs before relying on them, since the CLI changes quickly.
npm install -g instatunnel # New users: start the verification-email flow instatunnel auth login -e you@example.com # Existing users: save an API key instatunnel auth set-key "it_your_api_key" Enter fullscreen mode Exit fullscreen mode MCP mode
The README documents an optional header, X-InstaTunnel-MCP-Token, configured through an environment variable:
export INSTATUNNEL_MCP_TOKEN = "your-shared-secret" instatunnel 8787 --mcp --transport v2 Enter fullscreen mode Exit fullscreen mode The README does not say whether the edge enforces this header, strips it, or passes it through to your server. It also doesn’t say whether remote clients must send it themselves. Treat it as an outer gate and test its behavior in your own setup. Your server should enforce its own authentication and not depend on that header arriving.
The README describes HTTPS URLs. It does not describe end-to-end encryption to your machine, and it doesn’t describe header rewriting beyond the staged token. Don’t assume either.
MCP tool calls can run long and emit progress, so buffering is the main failure mode.
curl -N -i -X POST https://<subdomain>.instatunnel.my/mcp \ -H "Authorization: Bearer $MCP_TOKEN " \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' Enter fullscreen mode Exit fullscreen mode Look at the response headers and confirm that events arrive as they are produced.
Relying on an obscure URL is not a security control. If you pick a memorable subdomain like mymcp, anyone can guess it. Use layers so a failure in one doesn’t expose your tools:
This uses the official TypeScript SDK (v2), the stable line implementing the 2026-07-28 spec. The v1 @modelcontextprotocol/sdk package stays supported for at least six months after v2’s release but follows different APIs.
mkdir local-mcp-secure && cd local-mcp-secure npm init -y npm pkg set type = module npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express zod npm install -D tsx typescript @types/express @types/node Enter fullscreen mode Exit fullscreen mode Step 2: Write the server Create server.ts:
import { timingSafeEqual } from ' node:crypto '; import type { NextFunction, Request, Response } from ' express '; import { createMcpExpressApp } from ' @modelcontextprotocol/express '; import { toNodeHandler } from ' @modelcontextprotocol/node '; import { createMcpHandler, McpServer } from ' @modelcontextprotocol/server '; import * as z from ' zod/v4 '; const PORT = 8787; const TOKEN = process. env. MCP_TOKEN?? ''; const PUBLIC_HOST = process. env. MCP_PUBLIC_HOST; // e.g. acme-mcp-dev.instatunnel.my if ( TOKEN. length < 32 ) { console. error ( ' Set MCP_TOKEN to a random value of at least 32 characters. ' ); process. exit ( 1 ); } function tokenMatches ( presented: string ): boolean { const a = Buffer. from ( presented ); const b = Buffer. from ( TOKEN ); return a. length === b. length && timingSafeEqual ( a, b ); } function requireToken ( req: Request, res: Response, next: NextFunction ) { const [ scheme, presented ] = ( req. headers. authorization?? '' ). split ( ' ' ); if ( scheme!== ' Bearer ' ||! presented ||! tokenMatches ( presented )) { res. set ( ' WWW-Authenticate ', ' Bearer ' ). status ( 401 ). json ({ error: ' unauthorized ' }); return; } next (); } // A fresh McpServer is built for every request. const handler = createMcpHandler (() => { const server = new McpServer ({ name: ' local-notes ', version: ' 1.0.0 ' }); server. registerTool ( ' add-note ', { description: ' Echo a short note (demo tool, stores nothing) ', inputSchema: z. object ({ text: z. string (). max ( 500 ) }) }, async ({ text }) => ({ content: [{ type: ' text ', text: `Received: ${ text } ` }] }) ); return server; }); // Host/Origin validation: allow loopback names plus the tunnel's public hostname. const allowedHosts = [ ' 127.0.0.1 ', ' localhost ',...( PUBLIC_HOST? [ PUBLIC_HOST ]: [])]; const app = createMcpExpressApp ({ host: ' 0.0.0.0 ', allowedHosts }); const node = toNodeHandler ( handler ); app. all ( ' /mcp ', requireToken, ( req, res ) => void node ( req, res, req. body )); // Bind loopback only; the tunnel agent connects from the same machine. app. listen ( PORT, ' 127.0.0.1 ', () => { console. log ( `MCP server on http://127.0.0.1: ${ PORT } /mcp` ); }); Enter fullscreen mode Exit fullscreen mode Why it is written this way:
export MCP_TOKEN = " $( node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ) " export MCP_PUBLIC_HOST = "acme-mcp-dev.instatunnel.my" npx tsx server.ts Enter fullscreen mode Exit fullscreen mode Step 4: Open the tunnel In a second terminal:
export INSTATUNNEL_MCP_TOKEN = " $( node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ) " instatunnel 8787 --mcp --transport v2 --subdomain acme-mcp-dev instatunnel doctor --mcp Enter fullscreen mode Exit fullscreen mode Use a separate secret for the staged header and for the bearer token, so one leak doesn’t reveal both. If the server answers 403 on every tunneled request, compare the Host header the tunnel forwards with your allowedHosts list.
Check that an unauthenticated request is refused, whether by the edge or by your server’s 401:
curl -i -X POST https://acme-mcp-dev.instatunnel.my/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' Enter fullscreen mode Exit fullscreen mode Then check that an authenticated request works (add the staged header if your setup requires it):
curl -i -X POST https://acme-mcp-dev.instatunnel.my/mcp \ -H "Authorization: Bearer $MCP_TOKEN " \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' Enter fullscreen mode Exit fullscreen mode The SDK’s own example shows a tools/list reply arriving as a single SSE message event. Real MCP clients built on the SDK also send the MCP-Protocol-Version and Mcp-Method headers on their own.
The InstaTunnel README gives this shape for IDE or client settings. Key names vary by client, so check yours:
{ "mcpServers": { "instatunnel-remote": { "type": "streamable-http", "url": "https://acme-mcp-dev.instatunnel.my/mcp", "headers": { "Authorization": "Bearer YOUR_MCP_TOKEN" } } } } Enter fullscreen mode Exit fullscreen mode Never put the token in the URL. The spec forbids access tokens in query strings, and URLs leak into logs and history.
Stop the tunnel with Ctrl+C or instatunnel --kill acme-mcp-dev, then stop the server. Rotate both secrets before the next session.
Testing agents against local MCP servers is practical, as long as you treat the tunnel as publishing an endpoint with your privileges behind it. Use the official SDK so the transport and headers match the spec. Bind to loopback, validate Host and Origin, and require a bearer token in your own server. Add InstaTunnel’s staged token header as an extra outer gate, but verify how it behaves rather than assuming. Expose the smallest set of tools that does the job, and close the tunnel when the test is over.
Originally published at https://instatunnel.my/blog/local-first-ai-agent-testing-how-to-expose-local-mcp-servers-safely-without-compromising-host-security