> ## Documentation Index
> Fetch the complete documentation index at: https://docs.r5d.chat/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP Apps sandbox

> Enable inline MCP Apps and Browser MCP live view on a community installation with isolated sandbox hosts, DNS, TLS, and routing.

MCP Apps display an interactive view alongside an MCP tool result. This includes
Browser MCP's live browser viewer. Connecting an [MCP server](/configure/mcp)
is not enough: the operator must also enable a separate sandbox listener and
publish its per-connector HTTPS hosts. Text tool results work without it.

## Choose isolated origins

Use a **dedicated user-content domain on a different registrable site** from
the product. For example (replace both domains with ones you own):

| Setting | Example |
| - | - |
| Product `publicUrl` / `BASE_URL` | `https://chat.example.com` |
| Helm `mcpApps.sandboxOrigin` | `https://mcp-apps-{server}.example-usercontent.net` |
| Equivalent environment variable | `MCP_APPS_SANDBOX_ORIGIN=https://mcp-apps-{server}.example-usercontent.net` |
| One generated connector origin | `https://mcp-apps-0123456789abcdef01234567.example-usercontent.net` |
| Wildcard DNS and certificate | `*.example-usercontent.net` |

`{server}` is literal configuration, not a value you fill in yourself. The host
replaces it with a stable 24-character lowercase hexadecimal hash of the MCP
server registration ID. Different registrations get different origins.

The runtime enforces all of these rules:

* Exactly one `{server}` placeholder, in the **first DNS label**. The expanded
  label must fit the DNS limit of 63 characters.
* A fixed registrable site below the placeholder. Do not use
  `https://{server}.com` or `https://fixed.{server}.example-usercontent.net`.
* A different registrable site from `BASE_URL`, checked using the Public Suffix
  List including private suffixes. Changing the port or adding subdomains does
  not satisfy this rule.
* An HTTPS origin, without credentials, a non-root path, query, or fragment.
  HTTP is allowed only for loopback development, not for public sandbox hosts.

<Warning>
  `chat.example.com` and `sandbox.example.com` are **not** sufficiently
  isolated: both belong to `example.com`. Even `https://mcp-apps-{server}
      .sandbox.example.com` is rejected. Use an unrelated dedicated domain such as
  `example-usercontent.net`, not a shared domain that also hosts sensitive
  applications or sets parent-domain cookies.
</Warning>

Use the canonical product origin that users actually open. An alternate product
hostname on the sandbox site will not work. `BASE_URL`, the origin template, and
the existing `SETTINGS_ENCRYPTION_KEY` must agree between the product and sandbox.

## Community Helm installation

Follow [Install with Helm](/install/helm) and the
[Quickstart secret setup](/quickstart#create-the-namespace-and-secrets) first.
The examples below use release **r5d-chat**, namespace **r5d-chat**, and an
existing Secret **r5d-chat-secrets**. That Secret must contain `DATABASE_URL`,
`BETTER_AUTH_SECRET`, `SETTINGS_ENCRYPTION_KEY` (at least 32 characters),
`POSTGRES_PASSWORD` for the bundled database, and preferably `SETUP_CLAIM_TOKEN`.
The database password in `DATABASE_URL` must match `POSTGRES_PASSWORD`.

Choose a released chart/image pair that includes `mcpApps.sandboxOrigin`. Keep
the public community image defaults; no managed image or operator-only workflow
is needed. Select and pin a version rather than copying an old version number:

```bash theme={null}
helm repo add r5d-chat https://charts.r5d.dev
helm repo update
helm search repo r5d-chat/r5d-chat --versions
export CHART_VERSION='<reviewed-chart-version>'
helm show values r5d-chat/r5d-chat --version "$CHART_VERSION"
```

### Values

Save this as `values.yaml` for a **new installation**. Replace domains and all
three storage classes with your own. These are complete overrides on top of the
chart defaults; secrets remain in the existing Secret. This example manages
both TLS Ingress resources separately, so the chart's product Ingress is off.

```yaml theme={null}
edition: community
publicUrl: https://chat.example.com
existingSecret: r5d-chat-secrets

mcpApps:
  sandboxOrigin: 'https://mcp-apps-{server}.example-usercontent.net'
  resources:
    requests: { cpu: 25m, memory: 64Mi }
    limits: { cpu: 250m, memory: 256Mi }

ingress:
  enabled: false

postgresql:
  enabled: true
  storageClass: rook-ceph-block
  storageSize: 5Gi
workspace:
  storageClass: rook-ceph-block
  storageSize: 10Gi
artifactStorage:
  storageClass: rook-ceph-block
  storageSize: 10Gi

networkPolicy:
  enabled: true
  controlPlaneIngressFrom:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
      podSelector:
        matchLabels:
          app.kubernetes.io/name: traefik
```

The peer above selects Traefik pods **in kube-system** (the two selectors in one
peer are ANDed). Check your controller's actual namespace and labels and adjust
both. `networkPolicy.controlPlaneIngressFrom` is shared by product ingress on
port **3000** and sandbox ingress on **3002**. Preserve any other required
product ingress/tunnel peers. An empty list allows any source on those ports;
it does not mean deny-all. Enforcement requires a NetworkPolicy-capable CNI.

For an existing installation, merge only the MCP Apps settings and necessary
peer changes into your maintained release values. Do not replace existing
storage, authentication, images, scheduling, or product routes with this sample.
See [Chart values](/install/values) for other installation options.

### What is created

A nonempty `mcpApps.sandboxOrigin` makes the chart create:

* Deployment `r5d-chat-r5d-chat-mcp-apps`, using the same community image as the
  product and command `bun .output/automations/mcp-apps-sandbox.mjs`.
* ClusterIP Service `r5d-chat-r5d-chat-mcp-apps`, port **3002**.
* A sandbox NetworkPolicy when `networkPolicy.enabled` is true, with the ingress
  peers above and `egress: []`. Other policies must not grant it extra egress.

The sandbox receives only `BASE_URL`, `MCP_APPS_SANDBOX_ORIGIN`, and the settings
key from the existing Secret. It receives no database/auth credentials, mounts
no application storage, and does not mount a service-account token. The product
also receives `MCP_APPS_SANDBOX_ORIGIN` automatically.

**The chart does not create a sandbox Ingress, wildcard DNS, or certificate.**
The usual `ingress.*` values configure only the product route. With a different
release name, use the rendered `<fullname>-mcp-apps` Service name below.

## Wildcard DNS, TLS, and Ingress

Create an A/AAAA record for `chat.example.com` and a wildcard A/AAAA or CNAME
record for `*.example-usercontent.net`, pointing to your ingress endpoint.
Only publish IPv6 if that endpoint works over IPv6. A wildcard covers one DNS
label, which matches the generated `mcp-apps-<hash>` hosts.

Obtain a certificate for `chat.example.com` and one for
`*.example-usercontent.net`. For ACME wildcard issuance, use DNS-01 with your
DNS provider (HTTP-01 cannot issue wildcard certificates). Provision these TLS
Secrets in `r5d-chat`, or have your certificate controller create them:

```bash theme={null}
kubectl -n r5d-chat create secret tls chat-tls \
  --cert=/secure/path/chat-fullchain.pem --key=/secure/path/chat-key.pem
kubectl -n r5d-chat create secret tls mcp-apps-tls \
  --cert=/secure/path/wildcard-fullchain.pem --key=/secure/path/wildcard-key.pem
```

Save the following as `ingress.yaml`. It uses standard Kubernetes Ingress with
Traefik; adjust the class and controller-specific HTTPS listener settings to
your installation. For an existing product, keep its current route instead of
creating the first resource again.

```yaml theme={null}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: r5d-chat-product
  namespace: r5d-chat
spec:
  ingressClassName: traefik
  tls:
    - hosts: [chat.example.com]
      secretName: chat-tls
  rules:
    - host: chat.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: r5d-chat-r5d-chat
                port: { number: 80 }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: r5d-chat-mcp-apps
  namespace: r5d-chat
spec:
  ingressClassName: traefik
  tls:
    - hosts: ['*.example-usercontent.net']
      secretName: mcp-apps-tls
  rules:
    - host: '*.example-usercontent.net'
      http:
        paths:
          - path: /sandbox
            pathType: Exact
            backend:
              service:
                name: r5d-chat-r5d-chat-mcp-apps
                port: { number: 3002 }
```

Never send sandbox hosts to `r5d-chat-r5d-chat:80` (the authenticated product).
Do not configure a catch-all fallback from sandbox hosts to product routes.
Preserve the public `Host` and `/sandbox?t=...` path/query through every hop;
do not replace `Host` with a Service name or append the upstream port `3002`.

### External nginx TLS termination with HTTP upstream

HTTPS is required **at the browser**, not between a trusted TLS terminator and
the sandbox. Keep the template
`https://mcp-apps-{server}.example-usercontent.net` even when nginx uses an HTTP
upstream. The listener derives the scheme from that configured template and
validates the incoming public `Host`; it deliberately ignores
`X-Forwarded-Host` and `X-Forwarded-Proto` as authority substitutes.

For example, nginx outside Kubernetes can terminate wildcard TLS and forward to
a private HTTP ingress endpoint. In this topology, replace only the sandbox
Ingress above with this `sandbox-http-ingress.yaml` (no TLS section):

```yaml theme={null}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: r5d-chat-mcp-apps
  namespace: r5d-chat
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
  ingressClassName: traefik
  rules:
    - host: '*.example-usercontent.net'
      http:
        paths:
          - path: /sandbox
            pathType: Exact
            backend:
              service:
                name: r5d-chat-r5d-chat-mcp-apps
                port: { number: 3002 }
```

That internal `web` entrypoint must accept HTTP without an HTTPS redirect. Use
a dedicated private entrypoint if your normal HTTP entrypoint redirects; do not
disable HTTPS redirects on unrelated public routes. Restrict access to the
internal endpoint to your proxy. Save this server block in nginx's `http`
context, replacing the private endpoint and certificate paths:

```nginx theme={null}
upstream mcp_apps_ingress {
    server 10.0.0.10:80; # Private ingress endpoint reachable from nginx
}

server {
    listen 443 ssl;
    server_name *.example-usercontent.net;
    ssl_certificate     /etc/nginx/tls/usercontent-fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/usercontent-key.pem;

    # Do not inherit product SSO, caching, or HTML transforms here.
    auth_basic off;
    auth_request off;
    access_log off; # Avoid logging signed ?t= links

    location = /sandbox {
        proxy_pass http://mcp_apps_ingress;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header Cookie "";
        proxy_set_header Authorization "";
        proxy_set_header X-Forwarded-Host "";
        proxy_set_header X-Forwarded-Proto "";
        proxy_hide_header Set-Cookie;
        proxy_cache off;
        proxy_buffering off;
        proxy_intercept_errors off;
        proxy_redirect off;
    }
    location / { return 404; }
}
```

Keep the product virtual host separate and unchanged. Inspect inherited nginx,
CDN, and ingress configuration too: do not inject scripts or override the
sandbox's security headers. Run `nginx -t` before reloading your configuration.
If using a non-default public HTTPS port, include it in the template and retain
it in `Host`; never substitute the internal upstream port.

With the HTTP ingress hop, the sandbox normally sees **ingress-controller pods**,
not nginx, as the network source. If nginx can reach the sandbox Service directly
on the cluster network, you can instead proxy to that Service on port 3002 and
omit the sandbox Ingress. In that case allow nginx's actual pod or observed
source IP using `networkPolicy.controlPlaneIngressFrom` (for example a narrowly
scoped `ipBlock`), accounting for your CNI's SNAT behavior. Do not disable all
NetworkPolicy to fix a timeout. A tunnel connector likewise needs its own peer
and must preserve the public Host while targeting the sandbox Service.

## Why sandbox routes must stay bare

The sandbox serves signed, 15-minute policy links, not a second authenticated
copy of the product. Authentication and current MCP authorization are checked by
the product's `/api/mcp-apps` endpoint before it supplies the iframe content.

* **No cookies or auth middleware:** SSO/login redirects break the iframe
  handshake and put privileged pages or credentials on an untrusted-content
  route. Do not share product session cookies with it.
* **No HTML injection or rewriting:** analytics, CDN optimizers, banners, and
  script rewriting can break the message bridge or introduce code outside the
  resource's declared policy.
* **No caching:** preserve `Cache-Control: no-store`. Do not cache signed links
  or their responses, even if a CDN normally ignores query strings. Avoid
  recording full signed URLs in access logs, screenshots, or support reports.
* **Preserve security headers:** leave CSP, Permissions-Policy, and referrer
  policy intact. Do not add `X-Frame-Options: DENY`/`SAMEORIGIN` or a generic CSP
  that blocks the intended cross-site iframe.

Declared App network destinations may receive data the App sends. The host
filters out product and sandbox sites from those declarations; it cannot make
an untrusted server trustworthy. An unrelated domain dedicated to this content
reduces exposure to other applications' cookies and same-site privileges.
See [Security](/operate/security).

## Apply and verify

Render using the same pinned version and reviewed values that you will install.
Helm rendering checks Kubernetes configuration, **not** the runtime origin
rules above. Keep rendered output private, especially if you do not use an
existing Secret.

```bash theme={null}
RENDER_DIR="$(mktemp -d)"
helm template r5d-chat r5d-chat/r5d-chat --version "$CHART_VERSION" \
  --namespace r5d-chat -f values.yaml > "$RENDER_DIR/release.yaml"
kubectl apply --dry-run=server -f "$RENDER_DIR/release.yaml"
kubectl apply --dry-run=server -f ingress.yaml
# Review both manifests before applying.
helm upgrade --install r5d-chat r5d-chat/r5d-chat --version "$CHART_VERSION" \
  --namespace r5d-chat -f values.yaml --wait --timeout 10m
kubectl apply -f ingress.yaml
kubectl -n r5d-chat rollout status deployment/r5d-chat-r5d-chat
kubectl -n r5d-chat rollout status deployment/r5d-chat-r5d-chat-mcp-apps
kubectl -n r5d-chat get service r5d-chat-r5d-chat-mcp-apps
```

For external nginx, apply `sandbox-http-ingress.yaml` instead of the TLS sandbox
Ingress; keep your existing product routing. The sandbox validates its origin configuration before listening, but its
readiness probe is TCP only. A Ready pod does not prove that the product has
matching configuration or that public DNS, TLS, routing, or App authorization
works.

1. Check DNS and TLS for a generated-shaped host without bypassing certificate
   checks. This request **should return 403** because it has no signed ticket:

   ```bash theme={null}
   curl -sS -D - -o /dev/null \
     https://mcp-apps-0123456789abcdef01234567.example-usercontent.net/sandbox
   ```

   Expect `Cache-Control: no-store`, no `Set-Cookie`, and no login redirect.
   This is only a routing smoke test: an invalid sandbox configuration can also
   return 403. The sandbox has no `/api/health` endpoint.

2. Sign in at the canonical product URL. Add/test/trust an App-capable MCP server
   in **Settings → MCP**, select it in the chat, and request a fresh App-bound
   tool call (for Browser MCP, `browser_watch`). Old results without a recorded
   App binding remain text-only.

3. Click **Open interactive view** below the assistant's work. No iframe or App
   resource request is made until you open it. The saved tool result is delivered
   without rerunning the original tool call.

4. In browser developer tools, confirm `/api/mcp-apps` succeeds and the iframe's
   `/sandbox?t=...` loads with 200 from a generated host on the dedicated site.
   Do not copy its signed URL into support reports. **View connected** means the
   handshake and result delivery succeeded, not that live browser frames arrived.

5. Close/reopen the view, check the original text result is still readable, and
   verify current authorization: deselecting/disabling the MCP server must deny
   subsequent App requests. A different connector must use a different hostname.

### Browser MCP: inline viewer versus watch-link fallback

Browser MCP must already have a connected, approved **native CLI** browser and a
current MCP credential. Live pixels require a separate local opt-in on the
browser host; enabling this Helm feature does not turn them on:

```bash theme={null}
browser-mcp browser start --server https://your-browser-mcp.example \
  --profile work --live-view
```

Use your Browser MCP server URL, not the chat or sandbox URL. If that profile is
already running, stop it deliberately and restart the same profile with
`--live-view`; do not delete its profile or credentials. See the current
[Browser MCP native CLI guide](https://browser-mcp.mintlify.site/native-cli) and
[live-view guide](https://browser-mcp.mintlify.site/live-view).

`browser_watch` supplies an MCP App plus a dashboard watch link. With this
sandbox configured, open the inline viewer. Without a working inline viewer,
use the link from the tool's text result and sign in to the **Browser MCP
dashboard**; it is not an anonymous stream or a sandbox URL. Both modes still
require the native runtime's local live-view opt-in. Frames never enter tool
results and may show sensitive, unmasked page content.

The inline App uses the app-only `browser_watch_ticket` tool for short-lived
view-only tickets. r5d.chat prompts for **every App-initiated tool call**, including
ticket renewal or reconnection; approve only the expected requests. Declining
can leave the viewer waiting without making the underlying browser offline.

## Troubleshooting

The UI intentionally says **Interactive view unavailable** rather than exposing
upstream details. Product-side origin validation failures become the generic
`MCP App request failed` (502) response from `/api/mcp-apps`. The standalone
sandbox validates configuration on startup; a failure prevents it from listening
and can cause `CrashLoopBackOff`. Check its container logs for the validation
messages below. Rejected requests to a running listener fail closed with 403;
these validation messages are not guaranteed browser-visible error text.

| Symptom or error | Check / remedy |
| - | - |
| No interactive-view card | The tool must advertise `_meta.ui.resourceUri` (`ui://...`), and the saved result must have a host binding. Test a fresh App-capable call; do not blindly replay a mutating call. |
| `MCP Apps sandbox is not configured` | Nonempty template, canonical `BASE_URL`, and the same existing settings key of at least 32 characters in both processes. |
| `Sandbox origin requires one {server} placeholder` | Exactly one literal placeholder, not a wildcard `*` or a concrete host. |
| `Sandbox placeholder must be in the first DNS label` | Move the placeholder into the leftmost label; allow space for its 24-character expansion within 63 characters. |
| `Sandbox template must be below a fixed registrable site` / `Sandbox must have a registrable site` | Use a fixed, valid dedicated domain below a public suffix. |
| `Sandbox must use a separate registrable site` | Product and sandbox cannot share `example.com`, even with different ports/subdomains. Check the canonical `BASE_URL`. |
| `Invalid MCP Apps origin` | Use HTTPS origins only, without credentials/path/query/fragment. Internal HTTP upstream is fine; a public `http://` template is not. |
| TLS error, 404, 502, or timeout on the sandbox host | Check wildcard DNS/certificate coverage, ingress class/listener, Service name and port 3002, endpoints, and actual NetworkPolicy ingress source. Never route it to the product to get a 200. |
| 403 on a freshly minted `/sandbox?t=...` | Check preserved public Host and port, identical product URL/template/key, clocks, and unmodified query. Links expire after 15 minutes; explicitly close/reopen for a fresh link. Forwarded headers cannot repair a wrong Host. |
| Login redirect, cookies, or blocked frame | Remove sandbox SSO/cookie middleware, caching, HTML transforms, and conflicting CSP/frame headers. |
| `Browser session required` | Open at the canonical product origin with a current session; the App endpoint does not accept bearer authorization. |
| `MCP server authorization changed`, `MCP server is no longer selected`, or `MCP tools require renewed trust` | Restore the intended selection/authorization and review changed tools. After credential/definition changes, obtain a fresh authorized result rather than reusing an obsolete binding. |
| `Stored tool result is not available yet` | Let persistence finish, then explicitly reopen. |
| `App request limit reached` | Limits are four concurrent / 120 requests per minute per call per web process. Stop polling and wait before reopening. |
| View connected but no pixels | Check the native CLI's `--live-view`, browser connection/pause state, App ticket confirmation, and Browser MCP WebSocket reachability/CSP. A host handshake is not a frame-delivery test. |

## Rollback without resetting data

To disable inline views, set this in your **existing full release values** and
perform a reviewed Helm upgrade with the same chart/image versions:

```yaml theme={null}
mcpApps:
  sandboxOrigin: ''
```

The chart removes its sandbox Deployment/Service/NetworkPolicy and clears the
product template. Remove only the separately managed sandbox Ingress/nginx
route and, if desired, its dedicated DNS/TLS resources. Keep the product route
and other consumers of any shared certificate intact. Existing text results and
Browser MCP's dashboard watch-link fallback remain available.

**No database reset, PVC deletion, reinstall, or encryption-key rotation is
required.** Do not change `SETTINGS_ENCRYPTION_KEY` to troubleshoot: it protects
stored credentials and App bindings. Enabling/disabling this setting needs no
feature-specific data migration; ordinary release upgrades may still run their
normal migrations. See [Backup and restore](/operate/backup-restore) before any
unrelated data operation.

## Local development

Supply these settings and the same existing settings key to both processes:

```bash theme={null}
BASE_URL=http://127.0.0.1:3000
MCP_APPS_SANDBOX_ORIGIN='http://mcp-apps-{server}.localhost:3002'
```

Run `bun run dev` and, separately, `bun scripts/mcp-apps-sandbox.ts`. Open the
product at `127.0.0.1`, not `localhost` (which shares the sandbox's site).
`MCP_APPS_SANDBOX_HOST` and `MCP_APPS_SANDBOX_PORT` override only the standalone
listener's default `0.0.0.0:3002`; they do not change the public origin template
or the Helm Service port. See [Local development](/install/local-development).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.