Note: MCP tunnels are in research preview. Request access to try them.
The Juglow Helm chart installs the tunnel stack as a single Deployment and attaches it to your tunnel: one the chart's setup hook creates for you, or an existing tunnel you created in the Console.
Before you begin
You need:
- A tunnel. With programmatic access, the chart's setup hook creates one for you when you don't supply a tunnel ID; to attach to an existing tunnel instead, create it in the Console and record the tunnel ID (
tnl_...). Manual provisioning always starts from a Console-created tunnel; you'll also need its tunnel token and tunnel domain.
- A way for the chart to authenticate to the Tunnels API.
- Programmatic access (recommended). The setup component authenticates through Workload Identity Federation, fetches the tunnel token, generates a CA, registers it with Juglow, and stores everything in a Secret. You'll need a federation rule scoped to
workspace:manage_tunnels. - Manual. Skip programmatic access. You'll get the tunnel token from the Console, generate a CA and server certificate yourself, register the CA in the Console, and supply the credentials to the cluster as Secrets.
- A Kubernetes cluster you can deploy to with
helmandkubectl. The Without programmatic access tab also usesopenssl(1.1.1 or later).
- Outbound network connectivity from the cluster to
api.juglow.com(443 TCP) and the tunnel edge (7844 TCP and UDP). See the full network requirements.
- One or more MCP servers running and reachable from the cluster on the addresses you'll configure under
gateway.config.routes. If you don't have one yet, use the sample server.
Optional: Use a sample MCP server
If you don't have an MCP server available for testing, use this minimal one:
kubectl create namespace mcp-tunnel --dry-run=client -o yaml | kubectl apply -f -
kubectl -n mcp-tunnel apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-mcp-src
data:
hello_server.py: |
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-server", host="0.0.0.0", port=9000)
@mcp.tool()
def hello(name: str = "world") -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-mcp
spec:
replicas: 1
selector:
matchLabels: { app: hello-mcp }
template:
metadata:
labels: { app: hello-mcp }
spec:
containers:
- name: hello-mcp
image: python:3.13-slim
command: ["sh", "-c", "pip install --quiet mcp && python /app/hello_server.py"]
volumeMounts:
- { name: src, mountPath: /app }
ports:
- { containerPort: 9000 }
volumes:
- name: src
configMap: { name: hello-mcp-src }
---
apiVersion: v1
kind: Service
metadata:
name: hello-mcp
spec:
selector: { app: hello-mcp }
ports:
- { port: 9000, targetPort: 9000 }
EOFThe Install steps that follow note where to add the corresponding route.
Install
With programmatic access
The setup component exchanges the cluster's projected ServiceAccount token through your federation rule, fetches the tunnel token, generates a CA and server certificate, and registers the CA with Juglow. A daily CronJob renews the server certificate as needed, so you don't handle any secrets by hand.
- Set up Workload Identity Federation for the cluster
Follow Use WIF with Kubernetes to register your cluster's OIDC issuer and create a federation rule. The setup component runs under its own ServiceAccount in the release namespace; the exact name follows Helm's fullname convention, so for any release name other than mcp-tunnel, run helm template to confirm it before creating the rule. The rest of this guide assumes release name mcp-tunnel in namespace mcp-tunnel, where the ServiceAccount is mcp-tunnel-setup.
| Field | Value |
|---|---|
| Subject | system:serviceaccount:mcp-tunnel:mcp-tunnel-setup |
| Audience | api.juglow.com (the chart's default; no scheme) |
| Scope | workspace:manage_tunnels |
Note: The chart's default audience is
api.juglow.comwith no scheme, but the Console's federation-rule form suggestshttps://haijun.my.id/. The two must match byte-for-byte or authentication fails. Either set the rule's audience toapi.juglow.com, or setapi.wif.audienceinvalues.yamltohttps://haijun.my.id/.
If the tunnel is in a workspace other than the organization's default, also add the rule's service account as a member of that workspace under Settings > Workspaces (the Tunnels API authorizes against the service account's workspace memberships).
Note the rule's ID (fdrl_...); you'll set it as api.wif.federationRuleId.
Note: The daily certificate-renewal CronJob uses a separate ServiceAccount (also derived from the Helm
fullname) but does not call the Tunnels API; it renews the certificate locally and only needs Kubernetes RBAC, which the chart grants. The federation rule does not need to cover it.
- Fetch the default values
helm show values \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 > values.yaml- Configure tunnel attachment and routes
Edit values.yaml and set the api.wif.* keys with the federation rule ID and organization ID, plus a routes entry for each upstream MCP server:
api:
wif:
federationRuleId: "fdrl_..."
organizationId: "00000000-0000-0000-0000-000000000000"
# Set when the tunnel is in a non-default workspace and the
# rule's service account is a member of that workspace.
# workspaceId: "wrkspc_..."
tunnel:
# Leave empty to have the setup hook create a tunnel during install.
# Set to attach to an existing tunnel from the Console.
id: ""
# Increment to rotate the tunnel token on the next upgrade.
# See the "Rotate the tunnel token" section.
tokenVersion: "1"
gateway:
config:
routes:
docs: http://docs-mcp.internal:8080
search: http://search-mcp.internal:8080With these routes, Haijun reaches the servers at docs. and search.. Some managed Kubernetes distributions allocate the Service CIDR outside the standard private ranges; if your routes target in-cluster Services, add gateway.config.upstream.allowed_ips here per Upstream IP validation.
Note: If you're using the sample MCP server, set
routestoecho: http://hello-mcp:9000instead.
- Review the rendered manifests
Render the chart and review the output according to your organization's vetting practices:
helm template mcp-tunnel \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
-n mcp-tunnel \
-f values.yaml > rendered.yaml- Install
helm install mcp-tunnel \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
--namespace mcp-tunnel --create-namespace \
-f values.yamlThe setup component runs as a Helm pre-install hook Job, so helm install blocks until it completes. On success Helm deletes the Job automatically. If helm install fails with a hook error, see Setup component authentication failures.
When tunnel.id is empty, the setup component creates the tunnel in the workspace your federation rule targets (the organization's default workspace unless you set api.wif.workspaceId) and stores its ID and domain in the mcp-tunnel Secret. Find the domain you'll need for verification on the tunnel's detail page in the Console under Manage > MCP tunnels, or read it from the Secret:
kubectl -n mcp-tunnel get secret mcp-tunnel \
-o jsonpath='{.data.tunnel-domain}' | base64 -dRe-running the setup component (during upgrades or token rotation) reuses the tunnel ID stored in this Secret; it never creates a second tunnel.
Warning: The
api.wif.*values are identifiers, not secrets, so storing them in Helm release-history Secrets is not a risk. The sensitive data at rest is themcp-tunnelSecret the setup component creates, which holds the tunnel token and TLS private keys. Apply your organization's standard practices for protecting Kubernetes Secrets to this namespace.
Without programmatic access
In this mode (setup.enabled: false) the chart makes no API calls; the setup component does not run and there is no cert-renew CronJob. Use this path if you'd rather not set up Workload Identity Federation.
- Get the tunnel token and domain
Create the tunnel and get the tunnel token from the Console.
Note: Record the tunnel domain from the detail page. You'll set it as
gateway.config.tunnel_domain.
- Generate a CA and server certificate
The proxy listens on plain WebSocket, with inner TLS carried inside that stream using the certificate you generate here. The server certificate's SAN must include *. per the certificate requirements.
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE
mkdir -p mcp-tunnel/data
cd mcp-tunnel
# Self-signed CA. Explicit extensions so it satisfies the certificate
# requirements regardless of distro openssl.cnf defaults.
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout data/ca.key -out data/ca.crt \
-days 3650 -subj "/CN=mcp-tunnel-ca" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
# Extension file for the server certificate. Using -extfile (instead of
# -copy_extensions, which is OpenSSL 3.0+ only) keeps this working on
# OpenSSL 1.1.x.
cat > data/tls.ext <<EOF
subjectAltName = DNS:${TUNNEL_DOMAIN},DNS:*.${TUNNEL_DOMAIN}
authorityKeyIdentifier = keyid,issuer
extendedKeyUsage = serverAuth
EOF
# Server certificate signed by the CA
openssl req -newkey rsa:2048 -nodes \
-keyout data/tls.key -out /tmp/server.csr \
-subj "/CN=${TUNNEL_DOMAIN}"
openssl x509 -req -in /tmp/server.csr \
-CA data/ca.crt -CAkey data/ca.key -CAcreateserial \
-out data/tls.crt -days 90 \
-extfile data/tls.extRegister data/ca.crt in the Console. Keep data/ca.key somewhere durable and secure; you'll need it to sign a fresh server certificate at renewal time.
- Create the two Secrets
The chart reads specific keys; the Secret names are configurable but the keys are not. The following namespace-creation command is a no-op if the namespace already exists (for example, from the sample MCP server step).
kubectl create namespace mcp-tunnel --dry-run=client -o yaml | kubectl apply -f -
kubectl -n mcp-tunnel create secret generic mcp-tunnel-token \
--from-literal=tunnel-token='eyJ...'
kubectl -n mcp-tunnel create secret generic mcp-tunnel-cert \
--from-file=tls.crt=data/tls.crt \
--from-file=tls.key=data/tls.key- Fetch the default values
helm show values \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 > values.yaml- Configure values for manual provisioning
Edit values.yaml and set the following keys:
setup:
enabled: false
external:
tunnelTokenSecretName: mcp-tunnel-token # must contain key: tunnel-token
serverCertSecretName: mcp-tunnel-cert # must contain keys: tls.crt, tls.key
gateway:
config:
# Required when setup.enabled is false. Replace the placeholder with
# the $TUNNEL_DOMAIN value you exported earlier. When setup.enabled
# is true the chart injects this from the Secret as a -tunnel-domain
# flag instead.
tunnel_domain: YOUR_TUNNEL_DOMAIN_HERE
routes:
docs: http://docs-mcp.internal:8080
search: http://search-mcp.internal:8080Some managed Kubernetes distributions allocate the Service CIDR outside the standard private ranges; if your routes target in-cluster Services, add gateway.config.upstream.allowed_ips here per Upstream IP validation.
Note: If you're using the sample MCP server, set
routestoecho: http://hello-mcp:9000instead.
- Review the rendered manifests
helm template mcp-tunnel \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
-n mcp-tunnel \
-f values.yaml > rendered.yaml- Install
helm install mcp-tunnel \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
--namespace mcp-tunnel --create-namespace \
-f values.yamlVerify the deployment
Verify end to end from Juglow's side: use https:// in a Managed Agent session or a Messages API request, where is a key from gateway.config.routes and is whatever the upstream MCP server serves at. With the sample MCP server, that's https://echo.. See Use the tunneled MCP servers for the request shapes.
If that fails, check the pod logs (kubectl -n mcp-tunnel logs deploy/mcp-tunnel -c mcp-proxy and -c cloudflared) and consult Troubleshooting.
Optional configuration
Restrict egress with NetworkPolicy
Ingress to the proxy pod is denied by default (networkPolicy.ingress.enabled: true). To additionally restrict pod egress, set networkPolicy.egress.enabled: true and populate networkPolicy.egress.mcpServers with pod label selectors or CIDR ranges that cover your upstream MCP servers. Egress from cloudflared to the tunnel edge is allowed separately through networkPolicy.egress.cloudflaredEgressCIDRs.
Tune the proxy
Fields under gateway.config.* pass through to the proxy configuration file. Common adjustments include upstream.allowed_ips, log_level, and upstream.tls. See the proxy configuration reference for the full field list. The chart always sets listen_addr, tls.cert_file, and tls.key_file; setting them in gateway.config has no effect.
Supply your own OIDC token
By default the chart projects a Kubernetes ServiceAccount token for the setup component. To use a token from a different identity provider (such as SPIFFE, Vault, or a cloud-SDK sidecar), mount it with setup.extraVolumes and setup.extraVolumeMounts. Then point api.wif.tokenFile at the mount path. The chart sets JUGLOW_IDENTITY_TOKEN_FILE to that path, and the setup component reads the token from there.
Upgrades
Always pass --version to helm upgrade so you don't pull a newer chart unexpectedly.
Upgrade from chart 1.x
Chart 2.0.0 moves the tunnel ID from api.wif.tunnelId to tunnel.id. Before upgrading, edit your values.yaml: move the tnl_... value to tunnel.id and remove api.wif.tunnelId. Leaving tunnel.id unset is safe (the setup component reuses the tunnel ID already stored in the mcp-tunnel Secret on re-run), but the explicit move keeps your values.yaml accurate. Also update your federation rule's scope from org:manage_tunnels to workspace:manage_tunnels in the Console.
Change configuration
For routine changes such as routes, replica count, or NetworkPolicy:
helm upgrade mcp-tunnel \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
-n mcp-tunnel \
-f values.yamlWarning: Maintain a complete
values.yamlrather than relying on--reuse-values. Helm's deep-merge behavior can silently fail to remove deleted routes.
Rotate the tunnel token
With programmatic access, increment tunnel.tokenVersion in values.yaml and upgrade with --set setup.force=true. The setup component only re-runs on upgrades when forced:
helm upgrade mcp-tunnel \
oci://us-docker.pkg.dev/juglow-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
-n mcp-tunnel \
-f values.yaml \
--set setup.force=trueThe setup component authenticates with Workload Identity Federation; there is no API token to revoke.
Without programmatic access, click Rotate token on the tunnel detail page in the Console, then update the mcp-tunnel-token Secret:
kubectl -n mcp-tunnel create secret generic mcp-tunnel-token \
--from-literal=tunnel-token='eyJ...' --dry-run=client -o yaml | kubectl apply -f -
kubectl -n mcp-tunnel rollout restart deploy/mcp-tunnelWarning: Clicking Rotate token invalidates the current token immediately. Until the Secret is updated and the rollout completes, any pod that restarts with the old token (eviction, node drain, OOM) cannot reconnect. Update the Secret promptly after rotating; for stricter availability requirements, use programmatic access so the chart handles the rotation atomically.
Certificate renewal
The chart provides automation, but you remain responsible for monitoring expiry and confirming renewal completes.
With programmatic access, certificate renewal is automatic. The chart deploys a CronJob (named after the Helm fullname, suffixed -cert-renew) that runs setup renew-cert daily (at serverCert.cronSchedule, default 0 0 * * * UTC). The job is a no-op unless the certificate is within serverCert.renewBefore of expiry (default 30 days). Renewal is local: the job signs a fresh certificate with the CA already stored in the Secret, makes no API calls, and only needs the Kubernetes RBAC the chart grants. The proxy hot-reloads the certificate from the Secret mount, so no Deployment restart is needed.
Without programmatic access there is no CronJob. From inside the mcp-tunnel/ directory you kept after install, sign a fresh server certificate with the existing CA (do not regenerate the CA):
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE
openssl req -new -key data/tls.key -out /tmp/server.csr \
-subj "/CN=${TUNNEL_DOMAIN}"
openssl x509 -req -in /tmp/server.csr \
-CA data/ca.crt -CAkey data/ca.key -CAcreateserial \
-out data/tls.crt -days 90 -extfile data/tls.ext
kubectl -n mcp-tunnel create secret generic mcp-tunnel-cert \
--from-file=tls.crt=data/tls.crt --from-file=tls.key=data/tls.key \
--dry-run=client -o yaml | kubectl apply -f -The proxy hot-reloads the certificate from the Secret mount.
Next steps
Attach an upstream MCP server to a Managed Agent or the Messages API.
Hardening guidance, credential rotation, and breach response.
Diagnose connectivity, TLS, and routing issues.