MCP Remote Access
Ze's MCP server defaults to loopback binding (127.0.0.1). Two patterns make
it reachable from other machines:
- 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.
- 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.1on the remote side. This is correct -- do not setGatewayPorts yesinsshd_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
- With
bind-remote false(default), every server entry is force-rewritten to127.0.0.1at config extraction time, even if the operator writes0.0.0.0. - For tunnel-based deployments, use key-based SSH authentication for unattended tunnels. Disable password authentication on routers exposed to the internet.
- With WireGuard + socat, only peers in
AllowedIPscan reach the socat listener. This is your access control boundary. - Rotate WireGuard keys periodically. Revoke a peer by removing its
[Peer]block and reloading (wg syncconf wg0 <(wg-quick strip wg0)). - For native remote deployments, rotate the TLS cert before it expires. Certificate/key files are loaded at startup; replacing them requires a daemon restart. Authentication-mode, identity, token, issuer, audience and required-scope changes also require a restart. A config reload that changes those settings is rejected rather than accepted without taking effect.
- Each OAuth request validates its token locally. There is no token introspection or immediate per-token revocation at the authorization server. Rejected bearer credentials do not become audit actor names.
- JWKS refresh is rate-limited to 30 s minimum interval; an unknown-kid spray cannot trigger a JWKS-fetch flood against the AS.