Guide

MCP Remote Access

Ze's MCP server defaults to loopback binding (127.0.0.1). Two patterns make it reachable from other machines:

  1. Tunnel-based (recommended for dev): keep MCP on loopback, use SSH or WireGuard as the encrypted transport. Operator does not have to manage TLS certs.
  2. Native remote: bind MCP to a non-loopback address with authentication and TLS. Operator-managed certificate; client-side discovery via RFC 9728.

Option 1: Tunnel-based (loopback binding)

This is the Phase 1 posture. MCP binds 127.0.0.1, you tunnel from your client host. Ze's config verifier accepts auth-mode none on loopback, so no token is required. Add auth-mode bearer if the loopback is shared by untrusted local users (e.g., multi-tenant hosts).

Starting the MCP Server

ze start --mcp 8080 bgp.conf

This listens on 127.0.0.1:8080. Only local connections are accepted.

SSH Port Forwarding

SSH forwards a port on the remote machine's loopback to your local machine (or vice versa). The traffic is encrypted inside the SSH session.

Forward a Remote MCP Port to Your Local Machine

You have Ze running on router.example.com with --mcp 8080. You want to reach it from your laptop.

# On your laptop:
ssh -L 8080:127.0.0.1:8080 user@router.example.com

This binds 127.0.0.1:8080 on your laptop. Requests to localhost:8080 travel through the SSH tunnel and arrive at 127.0.0.1:8080 on the router.

# Test from your laptop:
curl --silent --show-error http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
        "io.modelcontextprotocol/protocolVersion":"2026-07-28",
        "io.modelcontextprotocol/clientCapabilities":{}}}}'

Let a Remote Machine Reach Your Local MCP

You have Ze running locally with --mcp 8080. You want a remote machine to reach it.

# On your local machine:
ssh -R 8080:127.0.0.1:8080 user@remote.example.com

This binds 127.0.0.1:8080 on the remote machine. Programs there can connect to localhost:8080 and the traffic tunnels back to your local Ze.

Note: By default, SSH remote forwards bind to 127.0.0.1 on the remote side. This is correct -- do not set GatewayPorts yes in sshd_config, as that would expose the port on all interfaces.

Running in the Background

Add -f -N to run the tunnel without an interactive shell:

ssh -f -N -L 8080:127.0.0.1:8080 user@router.example.com

-f backgrounds after authentication. -N skips shell execution.

To make it persistent across reboots, use autossh or a systemd unit:

# /etc/systemd/system/ze-mcp-tunnel.service
[Unit]
Description=SSH tunnel to Ze MCP
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/ssh -N -L 8080:127.0.0.1:8080 user@router.example.com
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Use key-based authentication (no passphrase, or with ssh-agent) for unattended tunnels.

Multiple MCP Servers

Forward different remote MCP ports to different local ports:

ssh -L 8081:127.0.0.1:8080 user@router-a.example.com &
ssh -L 8082:127.0.0.1:8080 user@router-b.example.com &

localhost:8081 reaches router-a, localhost:8082 reaches router-b.

WireGuard Tunnel

WireGuard creates a persistent encrypted tunnel between two machines. Each peer gets an IP address on the tunnel interface. Ze's MCP server still binds to 127.0.0.1, so you combine WireGuard with a local port forward using socat or SSH.

Setup Overview

[laptop wg0: 10.0.0.2] ---WireGuard--- [router wg0: 10.0.0.1]
                                           |
                                     127.0.0.1:8080 (ze --mcp 8080)

Router Side (where Ze runs)

# /etc/wireguard/wg0.conf
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <router-private-key>

[Peer]
PublicKey = <laptop-public-key>
AllowedIPs = 10.0.0.2/32

Ze binds MCP to 127.0.0.1:8080. The WireGuard interface alone does not expose it -- you need a local relay.

Option A: socat relay (lightweight)

socat TCP-LISTEN:8080,bind=10.0.0.1,fork TCP:127.0.0.1:8080

This listens on the WireGuard IP (10.0.0.1:8080) and forwards to Ze's loopback port. Only WireGuard peers can reach 10.0.0.1.

Option B: SSH over WireGuard (no relay needed)

Skip socat entirely. From the laptop, SSH to the router's WireGuard IP and forward the port:

ssh -L 8080:127.0.0.1:8080 user@10.0.0.1

This is the cleanest approach: WireGuard encrypts the outer transport, SSH forwards the port, and Ze never leaves loopback.

Laptop Side

# /etc/wireguard/wg0.conf
[Interface]
Address = 10.0.0.2/24
PrivateKey = <laptop-private-key>

[Peer]
PublicKey = <router-public-key>
Endpoint = router.example.com:51820
AllowedIPs = 10.0.0.1/32
PersistentKeepalive = 25

If using socat on the router:

curl --silent --show-error http://10.0.0.1:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
        "io.modelcontextprotocol/protocolVersion":"2026-07-28",
        "io.modelcontextprotocol/clientCapabilities":{}}}}'

If using SSH over WireGuard:

curl --silent --show-error http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
        "io.modelcontextprotocol/protocolVersion":"2026-07-28",
        "io.modelcontextprotocol/clientCapabilities":{}}}}'

Generating WireGuard Keys

wg genkey | tee privatekey | wg pubkey > publickey

Run on both machines. Exchange public keys. Keep private keys secret.

Which Approach to Use

Criteria SSH Forwarding WireGuard + socat WireGuard + SSH
Setup effort Minimal Moderate Moderate
Persistent With systemd/autossh ✓ With systemd/autossh
Multi-service One tunnel per port One tunnel, many ports One tunnel + SSH forward
MCP stays on loopback ✓ ✕ ✓
Extra software None (SSH is standard) WireGuard + socat WireGuard + SSH

Recommendation: SSH forwarding for ad-hoc access. WireGuard + SSH for permanent infrastructure where you already run WireGuard between sites.

Option 2: Native remote binding

When tunnelling is impractical (many clients, unattended fleets, OAuth-managed identities), bind MCP directly to a non-loopback address with TLS and authentication:

environment {
    mcp {
        enabled true;
        bind-remote true;
        auth-mode oauth;
        oauth {
            authorization-server https://auth.example/;
            audience             https://mcp.example/mcp;
            required-scopes      [ mcp.admin ];
        }
        tls {
            cert /etc/ze/mcp.pem;
            key  /etc/ze/mcp.key;
        }
        server public {
            ip 0.0.0.0;
            port 443;
        }
    }
}

Issuer and audience identifiers are exact, case-sensitive strings. Configure the issuer exactly as its RFC 8414 document reports it, including any trailing slash, and have the authorization server issue tokens with that exact iss and the configured aud. Neither identity is canonicalized. Both URLs must use HTTPS, have a host and contain no userinfo or fragment; the issuer cannot have a query. The audience may have a query, which also appears in the protected-resource metadata URL.

Discovery and JWKS fetches require trusted HTTPS, including every redirect. Loopback HTTP for the MCP listener behind a tunnel does not relax those outbound requirements. See Checking OAuth over HTTP for an authenticated request and rejection checks.

Alternatively for smaller deployments, use auth-mode bearer-list with per-identity tokens:

environment {
    mcp {
        enabled true;
        bind-remote true;
        auth-mode bearer-list;
        identity alice { token <long-random-string>; scope [ mcp.read mcp.write ]; }
        identity bob   { token <long-random-string>; scope [ mcp.read ]; }
        tls {
            cert /etc/ze/mcp.pem;
            key  /etc/ze/mcp.key;
        }
        server public {
            ip 0.0.0.0;
            port 443;
        }
    }
}

Config verify rejects unsafe combinations

ze config validate rejects at verify time:

Configuration Rejection
bind-remote true + auth-mode none bind-remote requires auth-mode != none
auth-mode oauth without oauth.authorization-server auth-mode=oauth requires oauth.authorization-server
auth-mode oauth without oauth.audience auth-mode=oauth requires oauth.audience
auth-mode oauth + non-loopback listener without TLS auth-mode=oauth requires tls.cert and tls.key on non-loopback listeners
auth-mode bearer-list without any identity entries auth-mode=bearer-list requires at least one identity
Issuer or audience with HTTP, userinfo, a fragment or no host Invalid OAuth URL
Issuer with a query, including an empty ? Invalid OAuth issuer
Only one of tls.cert and tls.key, in any auth mode Both TLS paths required

These checks run before MCP starts. A remote OAuth listener cannot silently fall back to plaintext, and a partial TLS configuration is never ignored.

Security Notes