From 7813d32cd00810eaa6177286d39e43a89203b9f3 Mon Sep 17 00:00:00 2001
From: gpt-5-codex <elope@esus.name>
Date: Sat, 11 Oct 2025 06:42:11 +0200
Subject: [PATCH] feat: add ngrok publishing helper

---
 docs/NGROK.md            |  41 ++++++++
 scripts/run_mcp_ngrok.sh | 212 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 253 insertions(+)
 create mode 100644 docs/NGROK.md
 create mode 100755 scripts/run_mcp_ngrok.sh

diff --git a/docs/NGROK.md b/docs/NGROK.md
new file mode 100644
index 0000000..a5e50af
--- /dev/null
+++ b/docs/NGROK.md
@@ -0,0 +1,41 @@
+# Publishing mcp-browser with ngrok
+
+The `scripts/run_mcp_ngrok.sh` helper launches `mcp-browser` in
+streamable-http mode and exposes it through an ngrok tunnel. By default it uses
+the `builtin-only` server profile so only the minimal proxy tools are visible.
+
+```bash
+./scripts/run_mcp_ngrok.sh \
+  --allow-origin https://platform.openai.com \
+  --ngrok-basic-auth "user:pass"
+```
+
+Key behaviours:
+
+- Picks a free local port and starts `mcp-browser --mode streamable-http` with
+  `--http-path /mcp`.
+- Starts `ngrok http` pointing at that port and prints the public URL once the
+tunnel is ready.
+- Writes logs to temporary files (paths shown on startup).
+- Cleans up both processes when interrupted.
+
+Useful options:
+
+- `--server chrome` – publish a different server entry from your config.
+- `--config ~/.claude/mcp-browser/config.yaml` – point at a custom config file.
+- `--ngrok-region eu` or `--ngrok-domain your-name.ngrok.app` – choose a region
+  or reserved domain.
+- `--ngrok-basic-auth user:pass` – require HTTP basic auth. Strongly recommended
+  if the tunnel is exposed to the public.
+
+Additional `mcp-browser` arguments can be passed after `--`, for example to
+connect to a streamable HTTP upstream:
+
+```bash
+./scripts/run_mcp_ngrok.sh -- \
+  --transport streamable-http \
+  --transport-url http://127.0.0.1:12306/mcp
+```
+
+The resulting public URL terminates at `/mcp`. Configure your MCP client (e.g.
+OpenAI’s MCP interface) with that URL and the optional basic auth credentials.
diff --git a/scripts/run_mcp_ngrok.sh b/scripts/run_mcp_ngrok.sh
new file mode 100755
index 0000000..b7cff29
--- /dev/null
+++ b/scripts/run_mcp_ngrok.sh
@@ -0,0 +1,212 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+  cat <<'USAGE'
+Usage: run_mcp_ngrok.sh [options] [-- additional mcp-browser args]
+
+Launch mcp-browser in streamable-http mode and expose it via an ngrok HTTPS
+endpoint. Designed to publish only the minimal built-in API (builtin-only
+server) unless overridden.
+
+Options:
+  --config PATH            Path to mcp-browser config file.
+  --server NAME            Server entry to use (default: builtin-only).
+  --http-host HOST         Local interface for the gateway (default: 127.0.0.1).
+  --http-port PORT         Local port for the gateway (default: auto).
+  --http-path PATH         HTTP path prefix (default: /mcp).
+  --allow-origin ORIGIN    Value for Access-Control-Allow-Origin header
+                           (default: https://platform.openai.com).
+  --ngrok-domain DOMAIN    Reserved ngrok domain to use (optional).
+  --ngrok-region REGION    ngrok region code (optional).
+  --ngrok-basic-auth USER:PASS  Require HTTP basic auth on the tunnel.
+  --ngrok-inspect true|false    Enable ngrok inspector (default: false).
+  --mcp-arg ARG             Extra argument passed to mcp-browser (repeatable).
+  --ngrok-arg ARG           Extra argument passed to ngrok (repeatable).
+  -h, --help                Show this help message.
+
+Environment overrides:
+  MCP_BROWSER_BIN  (default: mcp-browser)
+  NGROK_BIN        (default: ngrok)
+
+The script keeps both processes running and forwards termination signals.
+USAGE
+}
+
+MCP_BIN=${MCP_BROWSER_BIN:-mcp-browser}
+NGROK_BIN=${NGROK_BIN:-ngrok}
+CONFIG_PATH=""
+SERVER_NAME="builtin-only"
+HTTP_HOST="127.0.0.1"
+HTTP_PORT=""
+HTTP_PATH="/mcp"
+ALLOW_ORIGIN="https://platform.openai.com"
+NGROK_DOMAIN=""
+NGROK_REGION=""
+NGROK_BASIC_AUTH=""
+NGROK_INSPECT="false"
+MCP_EXTRA_ARGS=()
+NGROK_EXTRA_ARGS=()
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --config)
+      CONFIG_PATH=$2; shift 2;;
+    --server)
+      SERVER_NAME=$2; shift 2;;
+    --http-host)
+      HTTP_HOST=$2; shift 2;;
+    --http-port)
+      HTTP_PORT=$2; shift 2;;
+    --http-path)
+      HTTP_PATH=$2; shift 2;;
+    --allow-origin)
+      ALLOW_ORIGIN=$2; shift 2;;
+    --ngrok-domain)
+      NGROK_DOMAIN=$2; shift 2;;
+    --ngrok-region)
+      NGROK_REGION=$2; shift 2;;
+    --ngrok-basic-auth)
+      NGROK_BASIC_AUTH=$2; shift 2;;
+    --ngrok-inspect)
+      NGROK_INSPECT=$2; shift 2;;
+    --mcp-arg)
+      MCP_EXTRA_ARGS+=("$2"); shift 2;;
+    --ngrok-arg)
+      NGROK_EXTRA_ARGS+=("$2"); shift 2;;
+    -h|--help)
+      usage; exit 0;;
+    --)
+      shift
+      if [[ $# -gt 0 ]]; then
+        for arg in "$@"; do
+          MCP_EXTRA_ARGS+=("$arg")
+        done
+      fi
+      break;;
+    *)
+      echo "Unknown option: $1" >&2
+      usage
+      exit 1;;
+  esac
+done
+
+if ! command -v "$MCP_BIN" >/dev/null 2>&1; then
+  echo "Error: mcp-browser binary '$MCP_BIN' not found" >&2
+  exit 1
+fi
+if ! command -v "$NGROK_BIN" >/dev/null 2>&1; then
+  echo "Error: ngrok binary '$NGROK_BIN' not found" >&2
+  exit 1
+fi
+
+if [[ -z "$HTTP_PORT" ]]; then
+  HTTP_PORT=$(python3 - <<'PY'
+import socket
+s = socket.socket()
+s.bind(('127.0.0.1', 0))
+port = s.getsockname()[1]
+s.close()
+print(port)
+PY
+)
+fi
+
+HTTP_PATH="/${HTTP_PATH#/}"
+
+MCP_CMD=("$MCP_BIN" --mode streamable-http --server "$SERVER_NAME" \
+         --http-host "$HTTP_HOST" --http-port "$HTTP_PORT" \
+         --http-path "$HTTP_PATH" --http-allow-origin "$ALLOW_ORIGIN")
+if [[ -n "$CONFIG_PATH" ]]; then
+  MCP_CMD+=(--config "$CONFIG_PATH")
+fi
+[[ ${#MCP_EXTRA_ARGS[@]} -gt 0 ]] && MCP_CMD+=("${MCP_EXTRA_ARGS[@]}")
+
+MCP_LOG=$(mktemp -t mcp-browser-gateway.XXXXXX.log)
+NGROK_LOG=$(mktemp -t ngrok-mcp.XXXXXX.log)
+
+cleanup() {
+  [[ -n "${NGROK_PID:-}" ]] && kill "$NGROK_PID" >/dev/null 2>&1 || true
+  [[ -n "${MCP_PID:-}" ]] && kill "$MCP_PID" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT INT TERM
+
+"${MCP_CMD[@]}" >"$MCP_LOG" 2>&1 &
+MCP_PID=$!
+
+python3 - <<PY
+import socket, time, sys
+host, port = sys.argv[1], int(sys.argv[2])
+for _ in range(200):
+    sock = socket.socket()
+    try:
+        sock.connect((host, port))
+        sock.close()
+        sys.exit(0)
+    except OSError:
+        sock.close()
+        time.sleep(0.05)
+print('Port not ready', file=sys.stderr)
+sys.exit(1)
+PY
+"$HTTP_HOST" "$HTTP_PORT" || {
+  echo "Failed to start mcp-browser gateway. See $MCP_LOG" >&2
+  exit 1
+}
+
+NGROK_CMD=("$NGROK_BIN" http --scheme=http "http://$HTTP_HOST:$HTTP_PORT")
+NGROK_CMD+=(--request-header-add "X-MCP-Gateway:true")
+NGROK_CMD+=(--response-header-add "Cache-Control:no-store")
+NGROK_CMD+=(--inspect="$NGROK_INSPECT")
+if [[ -n "$NGROK_DOMAIN" ]]; then
+  NGROK_CMD+=(--domain "$NGROK_DOMAIN")
+fi
+if [[ -n "$NGROK_REGION" ]]; then
+  NGROK_CMD+=(--region "$NGROK_REGION")
+fi
+if [[ -n "$NGROK_BASIC_AUTH" ]]; then
+  NGROK_CMD+=(--basic-auth "$NGROK_BASIC_AUTH")
+fi
+[[ ${#NGROK_EXTRA_ARGS[@]} -gt 0 ]] && NGROK_CMD+=("${NGROK_EXTRA_ARGS[@]}")
+
+"${NGROK_CMD[@]}" >"$NGROK_LOG" 2>&1 &
+NGROK_PID=$!
+
+# Wait for ngrok API to report tunnel URL
+PUBLIC_URL=""
+for _ in {1..60}; do
+  sleep 0.5
+  PUBLIC_URL=$(python3 - <<'PY'
+import json
+import sys
+import urllib.request
+try:
+    with urllib.request.urlopen('http://127.0.0.1:4040/api/tunnels') as resp:
+        data = json.load(resp)
+except Exception:
+    sys.exit(1)
+for tunnel in data.get('tunnels', []):
+    url = tunnel.get('public_url')
+    if url:
+        print(url)
+        sys.exit(0)
+sys.exit(1)
+PY
+) && break
+  PUBLIC_URL=""
+done
+
+if [[ -z "$PUBLIC_URL" ]]; then
+  echo "ngrok tunnel did not start. See $NGROK_LOG" >&2
+  exit 1
+fi
+
+cat <<EOF
+mcp-browser gateway running on http://$HTTP_HOST:$HTTP_PORT$HTTP_PATH
+ngrok tunnel available at: $PUBLIC_URL$HTTP_PATH
+
+Local logs:
+  mcp-browser: $MCP_LOG
+  ngrok:       $NGROK_LOG
+
+Press Ctrl+C to stop.
