Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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
```

Expand Down
109 changes: 109 additions & 0 deletions bootstrap_auth.py
Original file line number Diff line number Diff line change
@@ -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 <url>``
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 <url> [<url>…] # 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:]))
Loading
Loading