# Hosting and hardening This covers two things people usually ask for together: keeping the MCP endpoint up even when your analysis machine reboots, and making sure nobody in the middle can read what goes over MCP. ## Read this first: what hosting can and can't do The MCP server is almost stateless. It is a registry of which IDA instances are alive plus a proxy that forwards tool calls to them. **All the real capability lives in the IDA instances**, which run on your analysis machine next to IDA. So: - Moving the server onto an always-on host (a small VPS) keeps the **endpoint** alive. Clients stay connected and never have to change the URL. When your Mac reboots and IDA comes back, the plugin re-registers on its own (the heartbeat already does this) and tools start working again with no manual step. - It does **not** give you functional high availability. If the machine running IDA dies, there is nothing to serve until it comes back — `ida_list_instances` just returns empty. Real HA would mean running IDA on more than one machine, which is a different project. Second thing to be clear-eyed about: now that the plugin exposes `exec_python` and `exec_idc`, the MCP channel is effectively **remote code execution** on the analysis machine. Transport encryption and strong auth are not optional niceties anymore — anyone who can read or reach the channel can run code on that box. Treat the token and the private key like SSH keys. ## Why a free HTTP tunnel is not private A plain-HTTP `ngrok` (or any HTTP tunnel) terminates TLS at the provider's edge and forwards your traffic to the agent in the clear. The tunnel from your machine to the provider is encrypted, but **the provider's servers see your plaintext** — the decompiler output, the addresses, everything. If the goal is "nobody learns what goes over MCP," an HTTP tunnel does not meet it. You need one of: - a VPN, so the transport is encrypted and the endpoint is not public at all, or - your own TLS endpoint, where the only machine that sees plaintext is one you control, or - end-to-end TLS through a pure TCP/TLS passthrough tunnel (the tunnel moves bytes it cannot read). ## Pick your shape based on who the clients are **If your MCP client is local** (Claude Code / desktop app on your own machine): put everything on a private network (Tailscale or WireGuard) and expose nothing publicly. This is the strongest option — there is no public endpoint to find, and every leg is encrypted by the VPN. **If your MCP client is a hosted connector** (e.g. a claude.ai connector that connects from someone else's servers): that client cannot join your VPN and usually cannot present a client certificate, so it needs a public HTTPS endpoint with a strong bearer token. Keep the plugin-to-server leg on the VPN so it never touches the public internet. The only machine that sees plaintext is your own VPS. Both shapes share the same back-channel: **the IDA plugins talk to the server over the VPN**, never over a public plaintext port. --- ## Build A — everything on a private network (local client, strongest) Tailscale is the low-footgun choice; raw WireGuard is the DIY equivalent. 1. Install Tailscale on the always-on host and on the analysis machine, and `tailscale up` on both. Each gets a stable `100.x.y.z` address. 2. On the always-on host, run the server bound to its tailnet address, with a token: ```bash MCP_BIND_HOST=100.x.y.z \ IDA_MCP_TOKEN=$(openssl rand -hex 32) \ python ida-mcp-server.py ``` 3. Point the IDA plugin at the server's tailnet address, and have it listen only on the analysis machine's own tailnet address: - `IDA_MCP_HOST` = the analysis machine's `100.x.y.z` (plugin listens only there) - `IDA_MCP_ADVERTISE_HOST` = the same address (what it reports to the server) - `IDA_MCP_REGISTRY_URL` = `http://100.x.y.z:8888` (the server's tailnet address) - `IDA_MCP_TOKEN` = the same token as the server 4. Point your local MCP client at `http://100.x.y.z:8888/sse` (the server's tailnet address). Nothing is published to the internet. Every hop is inside the tailnet, which is end-to-end encrypted. Plain HTTP is fine here because the network itself is the encryption layer — but keep the token set so a compromised tailnet device still can't drive it. --- ## Build B — public HTTPS front door + private back-channel (hosted client) Use this only when the client must reach you from the public internet. Layout: ``` hosted client ──HTTPS(443)──▶ Caddy (VPS) ──▶ uvicorn :8888 (VPS, localhost) ▲ analysis machine ──WireGuard/Tailscale──────────────┘ (plugin register + proxied calls) ``` 1. On the VPS, run the server with a token (bound to all interfaces; the firewall and Caddy decide who actually reaches it): ```bash MCP_BIND_HOST=0.0.0.0 IDA_MCP_TOKEN=$(openssl rand -hex 32) python ida-mcp-server.py ``` 2. Put Caddy in front for automatic Let's Encrypt TLS, and **only expose `/sse` and the OAuth paths** — block the instance registry from the public side (the plugin reaches it over the VPN, not through Caddy): ``` mcp.example.com { @registry path /instances /instances/* respond @registry "forbidden" 403 reverse_proxy 127.0.0.1:8888 } ``` 3. Firewall the VPS so the app port is never reachable directly from the internet — only 443 (Caddy) is public, and the app port is reachable from localhost and the VPN subnet: ```bash ufw default deny incoming ufw allow 443/tcp ufw allow in on wg0 # or the tailscale interface ufw allow 51820/udp # WireGuard handshake, if using raw WireGuard ufw enable ``` 4. Join the analysis machine to the VPN (Tailscale as in Build A, or WireGuard) and set the plugin's `IDA_MCP_REGISTRY_URL` / `IDA_MCP_ADVERTISE_HOST` to the VPS's VPN address, exactly as in Build A step 3. The plugin never uses the public hostname. 5. Point the hosted client at `https://mcp.example.com/sse` with the bearer token. --- ## Native TLS and mutual TLS (no reverse proxy) The server can serve HTTPS itself, and can require client certificates (mutual TLS) so that even someone who knows the URL cannot open a connection without a cert you issued. This is ideal when your client is your own script or Claude Code (which you can give a client cert); most hosted connectors cannot present one, so use token-over-TLS for those. Generate a CA, a server cert, and one client cert: ```bash # CA openssl genrsa -out ca.key 4096 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -subj "/CN=ida-mcp-ca" -out ca.crt # server cert (CN must match how the client addresses it) openssl genrsa -out server.key 4096 openssl req -new -key server.key -subj "/CN=mcp.example.com" -out server.csr openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 -sha256 -out server.crt # client cert openssl genrsa -out client.key 4096 openssl req -new -key client.key -subj "/CN=analyst-1" -out client.csr openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 -sha256 -out client.crt ``` Run with TLS (add the CA line for mutual TLS): ```bash MCP_TLS_CERT=server.crt \ MCP_TLS_KEY=server.key \ MCP_TLS_CLIENT_CA=ca.crt \ IDA_MCP_TOKEN=$(openssl rand -hex 32) \ python ida-mcp-server.py ``` With `MCP_TLS_CLIENT_CA` set, the server refuses any connection that doesn't present a cert signed by your CA. Bundle `client.crt` + `client.key` into the client (for `curl`: `--cert client.crt --key client.key --cacert ca.crt`). --- ## Keep it running (systemd on the VPS) ```ini # /etc/systemd/system/ida-mcp.service [Unit] Description=IDA MCP server After=network-online.target Wants=network-online.target [Service] WorkingDirectory=/opt/ida-mcp-server Environment=MCP_BIND_HOST=0.0.0.0 Environment=IDA_MCP_TOKEN=REPLACE_WITH_openssl_rand_hex_32 ExecStart=/usr/bin/python3 /opt/ida-mcp-server/ida-mcp-server.py Restart=always RestartSec=2 # hardening NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/opt/ida-mcp-server PrivateTmp=true [Install] WantedBy=multi-user.target ``` ```bash systemctl daemon-reload && systemctl enable --now ida-mcp ``` `Restart=always` covers a server crash; the plugin's heartbeat covers an IDA restart on your side. Together the endpoint stays up across both, without anyone reconfiguring a URL. ## Checklist - [ ] Token set (`openssl rand -hex 32`), same value on server and every plugin. - [ ] Plugin-to-server traffic goes over a VPN, never a public plaintext port. - [ ] No free HTTP tunnel in the path (it can read everything). VPN, own TLS, or TLS passthrough only. - [ ] Public exposure (if any) is 443 behind a proxy or native TLS; the app port is firewalled off the internet. - [ ] `/instances*` is not reachable from the public side. - [ ] Remember `exec_python`/`exec_idc` are RCE on the analysis box — the token and any private key are as sensitive as an SSH key.