diff --git a/README.md b/README.md index 41d852b..7c31a0b 100755 --- a/README.md +++ b/README.md @@ -872,12 +872,33 @@ token survives restarts. In dev, `docker-compose.override.yml` bind-mounts `./.m #### Headless / one-time bootstrap -The OAuth redirect targets `localhost:3334`. Either authorize via the wizard link with port -`3334` mapped (the default), **or** run the flow once on the host to pre-populate the cache, -then start the proxy with the same dir mounted: +The OAuth redirect targets `localhost:3334`, so that port must be reachable from the +machine running your browser (the default `docker-compose.yml` maps it). The **first** +grant always needs a human in a browser once โ€” there's no static key โ€” but mcpproxy +automates everything around it so you rarely touch a host shell: + +**1. Automatic refresh on restart.** On startup the proxy *warms* every `mcp-remote` +bridge it finds in your configs: with a valid cache the token refreshes silently before +the first tool call; if re-authorization is needed, the authorization URL is logged and +surfaced as a banner in the UI. Disable with `MCPPROXY_WARM_REMOTE=0`. + +**2. Bootstrap from the browser (no host shell / `docker exec`).** The UI has a built-in +terminal. In **+ New Provider โ†’ Remote MCP Server**, click **๐Ÿ–ฅ Bootstrap / Authorize in +terminal** to run `npx -y mcp-remote ` live, watch its output, click the auth link, +and complete the flow โ€” the token cache is written under `MCP_REMOTE_CONFIG_DIR`. An +existing provider whose refresh token has lapsed shows a **๐Ÿ” Re-authorize** button in its +editor that does the same. The terminal is gated by `MCPPROXY_WEB_TERMINAL` (default on); +set it to `0` to disable. *(It's a real shell in the container โ€” keep the UI on a trusted +network, as you already must for the secrets editor and command introspection.)* + +**3. Pre-populate on the host.** If you'd rather warm the cache before the container ever +starts, run the flow once on the host (deriving URLs from your configs) and start the proxy +with the same dir mounted: ```bash -MCP_REMOTE_CONFIG_DIR=./.mcp-auth npx -y mcp-remote https://mcp.asana.com/v2/mcp +./run_local.sh --bootstrap-auth # every configured mcp-remote provider +./run_local.sh --bootstrap-auth https://mcp.asana.com/v2/mcp # or an explicit URL +# equivalently: MCP_REMOTE_CONFIG_DIR=./.mcp-auth python3 bootstrap_auth.py # authorize in the browser, then `docker compose up` โ€” tools work with no further prompts ``` diff --git a/bootstrap_auth.py b/bootstrap_auth.py new file mode 100644 index 0000000..959e6f6 --- /dev/null +++ b/bootstrap_auth.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +bootstrap_auth.py โ€” Pre-populate the mcp-remote OAuth token cache on the host. + +Remote, OAuth-protected MCP servers are bridged with ``npx -y mcp-remote `` +and cache their tokens under ``MCP_REMOTE_CONFIG_DIR``. The very first OAuth +grant needs a human in a browser once; afterwards mcp-remote refreshes silently. + +This script reads the configured providers, finds every ``package.command`` that +runs ``mcp-remote``, and runs each one with stdin/stdout attached so you can +complete the browser flow. The resulting token cache is written to +``MCP_REMOTE_CONFIG_DIR`` (default ``./.mcp-auth``, matching the dev +docker-compose bind mount) so a subsequent ``docker compose up`` starts with the +cache already warm โ€” no interactive prompts in the container. + +Usage: + python3 bootstrap_auth.py # bootstrap every remote provider + python3 bootstrap_auth.py [โ€ฆ] # bootstrap explicit URL(s) + +Environment: + MCP_TOOL_CONFIG_DIR where provider YAMLs live (default ./tools, then /app/tools) + MCP_REMOTE_CONFIG_DIR where mcp-remote caches tokens (default ./.mcp-auth) +""" + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import yaml + + +def _config_dir() -> Path: + env = os.environ.get("MCP_TOOL_CONFIG_DIR") + if env: + return Path(env) + if Path("tools").is_dir(): + return Path("tools") + return Path("/app/tools") + + +def extract_remote_commands(specs: list[dict]) -> list[str]: + """Return every package command that bridges a remote server via mcp-remote.""" + commands: list[str] = [] + for spec in specs: + pkg = spec.get("package") or {} + command = (pkg.get("command") or "").strip() + if command and "mcp-remote" in command and command not in commands: + commands.append(command) + return commands + + +def command_url(command: str) -> str | None: + """Best-effort: pull the first http(s) URL out of an mcp-remote command.""" + for token in shlex.split(command): + if token.startswith("http://") or token.startswith("https://"): + return token + return None + + +def load_specs(config_dir: Path) -> list[dict]: + specs: list[dict] = [] + for path in sorted(config_dir.glob("*.yaml")): + try: + specs.append(yaml.safe_load(path.read_text(encoding="utf-8")) or {}) + except Exception as exc: # noqa: BLE001 + print(f" ! skipping {path.name}: {exc}") + return specs + + +def run_bootstrap(commands: list[str], cache_dir: Path) -> None: + cache_dir.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env["MCP_REMOTE_CONFIG_DIR"] = str(cache_dir) + print(f"โ†’ token cache: {cache_dir}\n") + for command in commands: + url = command_url(command) or command + print(f"=== Authorizing {url} ===") + print(f" running: {command}") + print(" Complete the browser flow, then press Ctrl-C to continue.\n") + try: + subprocess.run(shlex.split(command), env=env, check=False) + except KeyboardInterrupt: + print("\n (moving on)\n") + print("โœ“ Done. Start the proxy with the same MCP_REMOTE_CONFIG_DIR mounted.") + + +def main(argv: list[str]) -> int: + if argv: + commands = [f"npx -y mcp-remote {url}" for url in argv] + else: + config_dir = _config_dir() + if not config_dir.is_dir(): + print(f"โœ— config dir not found: {config_dir} (set MCP_TOOL_CONFIG_DIR)") + return 1 + commands = extract_remote_commands(load_specs(config_dir)) + if not commands: + print(f"No mcp-remote providers found in {config_dir}. " + "Pass server URL(s) explicitly to bootstrap them anyway.") + return 0 + + cache_dir = Path(os.environ.get("MCP_REMOTE_CONFIG_DIR", "./.mcp-auth")) + run_bootstrap(commands, cache_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/frontend/app.py b/frontend/app.py index ede1226..3ce3e8f 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -15,17 +15,24 @@ GET /api/env โ€” list .env vars (values masked) POST /api/env โ€” upsert vars into .env {vars: {KEY: VALUE}} POST /api/restart โ€” send SIGTERM to restart server +GET /api/config โ€” UI feature flags (e.g. web_terminal) +WS /ws/terminal โ€” interactive PTY terminal (optional ?cmd=โ€ฆ) """ import ast import asyncio +import fcntl import json import os +import pty import re import shlex +import shutil import signal +import struct import subprocess import sys +import termios import textwrap import threading import traceback @@ -33,12 +40,29 @@ from typing import Any import yaml -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from config import CONFIG_DIR, ENV_FILE, REPOS_DIR +# --------------------------------------------------------------------------- +# Web terminal feature gate +# --------------------------------------------------------------------------- +# +# The /ws/terminal endpoint streams a real PTY to the browser so the mcp-remote +# OAuth bootstrap (and any other command) can be driven from the UI without a +# host shell or `docker exec`. This is arbitrary command execution over HTTP โ€” +# consistent with what the proxy already does (introspect spawns arbitrary +# commands; code providers exec() Python) and intended for a trusted, +# single-user/local admin UI. It can be switched off with MCPPROXY_WEB_TERMINAL=0. + +def _web_terminal_enabled() -> bool: + return os.environ.get("MCPPROXY_WEB_TERMINAL", "1").strip().lower() not in ( + "0", "false", "no", "off", "" + ) + + # --------------------------------------------------------------------------- # .env helpers # --------------------------------------------------------------------------- @@ -831,6 +855,108 @@ def _send(): threading.Thread(target=_send, daemon=True).start() return {"ok": True} + # โ”€โ”€ Client config (feature flags) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @app.get("/api/config") + async def client_config() -> dict: + """Expose UI feature flags so the front end can hide disabled features.""" + return {"ok": True, "web_terminal": _web_terminal_enabled()} + + # โ”€โ”€ Interactive web terminal (PTY over WebSocket) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @app.websocket("/ws/terminal") + async def terminal_ws(ws: WebSocket) -> None: + """Bridge a browser xterm.js session to a PTY-backed subprocess. + + With an optional ``?cmd=`` query the given command is run (used by the + wizard / Re-authorize buttons to launch ``npx -y mcp-remote `` so + the OAuth bootstrap can be completed entirely from the browser); with no + ``cmd`` an interactive login shell is started. The subprocess inherits + the proxy's environment, so MCP_REMOTE_CONFIG_DIR (set by compose) points + the token cache at the persisted volume automatically. + """ + await ws.accept() + if not _web_terminal_enabled(): + await ws.send_text("\r\n[web terminal disabled โ€” set MCPPROXY_WEB_TERMINAL=1 to enable]\r\n") + await ws.close(code=1008) + return + + cmd = (ws.query_params.get("cmd") or "").strip() + shell = shutil.which("bash") or shutil.which("sh") or "/bin/sh" + argv = [shell, "-lc", cmd] if cmd else [shell, "-il"] + + pid, master_fd = pty.fork() + if pid == 0: + # Child: exec the shell/command with the PTY as its controlling tty. + try: + os.execvpe(argv[0], argv, os.environ.copy()) + except Exception: + os._exit(127) + + loop = asyncio.get_running_loop() + + def _set_winsize(rows: int, cols: int) -> None: + try: + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, + struct.pack("HHHH", max(rows, 1), max(cols, 1), 0, 0)) + except OSError: + pass + + async def pty_to_ws() -> None: + while True: + try: + data = await loop.run_in_executor(None, os.read, master_fd, 65536) + except OSError: + data = b"" # PTY closed (child exited) โ†’ EIO on Linux + if not data: + break + await ws.send_bytes(data) + + async def ws_to_pty() -> None: + while True: + msg = await ws.receive() + if msg.get("type") == "websocket.disconnect": + break + text = msg.get("text") + if text is not None: + try: + ctrl = json.loads(text) + except (ValueError, TypeError): + ctrl = None + if isinstance(ctrl, dict) and "resize" in ctrl: + cols, rows = ctrl["resize"] + _set_winsize(int(rows), int(cols)) + continue + if isinstance(ctrl, dict) and "input" in ctrl: + os.write(master_fd, str(ctrl["input"]).encode()) + continue + os.write(master_fd, text.encode()) + elif msg.get("bytes") is not None: + os.write(master_fd, msg["bytes"]) + + reader = asyncio.ensure_future(pty_to_ws()) + writer = asyncio.ensure_future(ws_to_pty()) + try: + await asyncio.wait({reader, writer}, return_when=asyncio.FIRST_COMPLETED) + except WebSocketDisconnect: + pass + finally: + for task in (reader, writer): + task.cancel() + try: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + except OSError: + pass + try: + os.close(master_fd) + except OSError: + pass + try: + await ws.close() + except RuntimeError: + pass + # โ”€โ”€ HTML UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @app.get("/", response_class=HTMLResponse) @@ -853,6 +979,7 @@ async def index(): +