Skip to content

HTTP Proxy

Some apps need to render content from an HTTP server they control — an astro preview of a user’s site, a Jupyter kernel gateway, a notebook renderer, anything that’s easier to ship as a server than as a static ui:// resource.

The http-proxy primitive lets a bundle expose its own loopback HTTP server through the platform’s web origin. The browser hits a same-origin path, the platform forwards it to the bundle’s local port, and the response comes back stripped of credentials and embeddable in an iframe.

This is opt-in per bundle and per workspace. Most apps don’t need it.

Reach for http-proxy when:

  • Your app’s UI is a server-rendered page (Astro, Next.js, Rails, Django) and a static ui:// resource isn’t enough.
  • You need URL-based navigation inside the iframe (deep links, multi-page sites).
  • Your underlying tool is an HTTP service (Jupyter, a renderer, a dashboard).

Don’t reach for it when:

  • A static HTML ui:// resource works. Use UI Resources — no extra trust surface.
  • You only need to make API calls from a ui:// page. Use useCallTool or fetch directly; declare the host in _meta.ui.csp.connectDomains.

A bundle that runs astro preview on 127.0.0.1:4321 and wants the browser to load it:

manifest.json
{
"_meta": {
"ai.nimblebrain/http-proxy": {
"target": "http://127.0.0.1:4321",
"mount": "preview",
"websocket": true
}
}
}

The platform exposes that server at:

/v1/ws/<workspaceId>/apps/<bundleName>/preview/*

For a bundle named @org/my-app running for workspace ws_demo, the URL is /v1/ws/ws_demo/apps/my-app/preview/. The bundle is responsible for spawning, lifecycle-managing, and serving its loopback HTTP server — the platform only proxies.

_meta["ai.nimblebrain/http-proxy"]:

FieldTypeRequiredDescription
targetstringYesURL of the bundle’s HTTP server. Must point at a loopback host (127.0.0.1, ::1, localhost) and use http:// or https://.
mountstringYesSingle path segment under /apps/<bundle>/. Cannot be resources, tools, mcp, or events (reserved). Cannot contain /.
websocketbooleanNoHints that the upstream supports WebSocket upgrades (HMR, live channels). Declared today; upgrade forwarding is not yet wired through the route.

If target doesn’t pass the loopback check, the proxy is silently disabled and a warning logs at bundle start. See Why loopback only below.

/v1/ws/<workspaceId>/apps/<bundleName>/<mount>/<rest>

The full incoming path is forwarded to the upstream verbatim. That means your loopback server must serve content at the same path — usually by configuring a base URL.

For Astro:

Terminal window
astro preview --base /v1/ws/${NB_WORKSPACE_ID}/apps/my-app/preview

For other servers, set the equivalent base path / prefix option. Without this, absolute URLs in your HTML won’t line up with the public path and the page will 404 its own assets.

The platform injects NB_PROXY_PREFIX (see below) so you don’t have to assemble the path yourself.

The platform injects three env vars into your bundle’s process when http-proxy is declared and the platform has a workspace context:

VariableValueUse it for
NB_WORKSPACE_IDThe workspace this instance is running forPer-workspace data scoping; building URLs that include the workspace segment.
NB_PROXY_PREFIX/v1/ws/<wsId>/apps/<bundle>/<mount>Pass to your upstream as its base path (astro preview --base $NB_PROXY_PREFIX, equivalent for others).
NB_PUBLIC_ORIGINThe platform’s browser-facing origin (e.g. http://localhost:27246)Declare in your ui:// resource’s CSP so the iframe can frame the proxied URL. Operator-set; falls back to first ALLOWED_ORIGINS entry.

If NB_PUBLIC_ORIGIN isn’t set, omit the CSP block entirely — the host applies its restrictive default and the iframe simply won’t load. That’s the right failure mode: visible, fixable by setting one env var.

To frame the proxied URL inside your ui:// resource, declare it in the resource’s CSP:

server.py
import os
from fastmcp import FastMCP
mcp = FastMCP("my-app")
def _ui_csp_meta() -> dict:
public_origin = (os.getenv("NB_PUBLIC_ORIGIN") or "").rstrip("/")
if not public_origin:
return {}
return {
"ui": {
"csp": {
"frameDomains": [public_origin],
"connectDomains": [public_origin],
}
}
}
@mcp.resource(
"ui://my-app/main",
mime_type="text/html;profile=mcp-app",
meta=_ui_csp_meta() or None,
)
def main_ui() -> str:
return load_html()

The iframe inside your UI resource then loads:

<iframe src={`${publicOrigin}/v1/ws/${wsId}/apps/my-app/preview/`}></iframe>

The platform reads NB_PUBLIC_ORIGIN and NB_WORKSPACE_ID and surfaces them however your UI prefers (inlined into the HTML, set as <meta> tags, fetched at runtime).

A bundle declaring http-proxy runs as same-origin code in the authenticated user’s session. The iframe loaded from the proxy is sandboxed allow-scripts allow-same-origin, which means the bundle’s preview JS can read cookies for the platform origin, call same-origin REST APIs as the user, and read top-frame DOM where the host UI permits it.

Treat http-proxy bundles like browser extensions: the operator vouches for the code.

DefenseWhere
target restricted to loopback hostsManifest parse; off-loopback is rejected silently with a warning.
Authorization, Cookie, X-Workspace-Id stripped from forwarded requestsBundle’s loopback server cannot read user credentials.
Set-Cookie / Set-Cookie2 stripped from upstream responsesBundle cannot plant cookies on the platform’s origin.
Per-request workspace membership checkwsId from the URL is verified against the authenticated identity.
Workspace.allowHttpProxy = falsePer-workspace kill switch.
Content-Security-Policy and X-Frame-Options from upstream are replacedPlatform sets X-Frame-Options: SAMEORIGIN; cross-origin embedding stays denied.

What the platform does NOT defend against today

Section titled “What the platform does NOT defend against today”
  • Cross-origin isolation per bundle. All bundles run on the platform’s origin. A malicious bundle that the user installed could attack other same-origin code. For untrusted-bundle marketplaces, the next investment is subdomain-per-bundle + COEP.
  • WebSocket upgrades. Declared today ("websocket": true in the manifest), but not yet forwarded through the route. HTTP methods only.

The proxy primitive exists for bundles to expose their own local HTTP server. There is no legitimate reason for the target to point anywhere else.

Allowing arbitrary hosts would turn the proxy into an SSRF gadget that can reach:

  • Cloud metadata services (169.254.169.254)
  • Internal RFC1918 networks
  • Arbitrary external endpoints

…all with the authenticated user’s credentials attached. Loopback-only closes that off.

Operators can disable http-proxy globally for a workspace:

workspace.json
{
"id": "ws_demo",
"allowHttpProxy": false
}

When set, every proxy request for that workspace returns 403 proxy_disabled regardless of which bundles declare http-proxy. Default is true (enabled).

  • Loopback hosts only. See Why loopback only.
  • HTTP methods only. WebSocket upgrade is declared but not yet forwarded — your astro preview will work, but Vite HMR behind the proxy won’t.
  • Single mount per bundle. Declare one HTTP server per bundle. If you need multiple, run a router on a single port.
  • Bundle owns the lifecycle. The platform doesn’t spawn or supervise your loopback server. Start it in your MCP server’s boot phase, kill the process group on shutdown.

synapse-astro-editor is the first bundle to use http-proxy in production. It runs astro build + astro preview against a user’s GitHub repo and serves the rendered site through the proxy. Worth reading for:

  • How to spawn a long-running upstream server with proper process-group cleanup (src/mcp_astro_editor/astro_runtime.py).
  • How to declare the CSP block on ui:// resources from NB_PUBLIC_ORIGIN (src/mcp_astro_editor/server.py).
  • How to thread NB_PROXY_PREFIX into the upstream server’s base config.