diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 313cbad..7916189 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,9 @@ jobs: pip install --upgrade pip pip install -r requirements.txt -r requirements-dev.txt + - name: Install uv (for uvx-based provider support) + run: pip install uv + - name: Run tests run: pytest tests/ -v --tb=short diff --git a/Dockerfile b/Dockerfile index cd22204..6a0dfa1 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY server.py config.py npx_runner.py ./ +# Install uv so that uvx-based MCP package providers work out of the box. +# uv installs its binaries to /root/.local/bin; add that to PATH. +RUN pip install --no-cache-dir uv +ENV PATH="/root/.local/bin:$PATH" + +COPY server.py config.py process_runner.py ./ COPY frontend/ ./frontend/ COPY handlers/ ./handlers/ diff --git a/README.md b/README.md index 49e58e3..0bdc785 100755 --- a/README.md +++ b/README.md @@ -11,11 +11,12 @@ Each tool **provider** is a single YAML file under `tools/`. The YAML contains: - The Python code for all tool functions (embedded directly in the file) - One or more tool declarations that reference those functions - Per-tool input schemas, secrets, and auth metadata -- Or an `npx:` block to delegate to an existing MCP npm package +- Or a `package:` block to delegate to any existing MCP subprocess server — launched + via `npx`, `uvx`, `python -m`, or any installed binary -`server.py` loads every YAML at startup, executes its `code` block (or spawns the -declared `npx` process), and registers each declared tool automatically — no Python -files to maintain separately, no changes to `server.py` needed when adding new tools. +`server.py` loads every YAML at startup, installs declared `requirements` (pip packages), +runs `setup_commands`, then registers each tool automatically — no Python files to +maintain separately, no changes to `server.py` needed when adding new tools. ## Ports @@ -36,7 +37,7 @@ files to maintain separately, no changes to `server.py` needed when adding new t ├── requirements-dev.txt ← test dependencies ├── server.py ├── config.py ← shared env-var config (imported by all modules) -├── npx_runner.py ← spawns & proxies npx-based MCP providers +├── process_runner.py ← spawns & proxies any stdio MCP subprocess ├── frontend/ │ └── app.py ← FastAPI UI server (port 8889) ├── .env.example @@ -76,7 +77,7 @@ Click **+ New Provider** and choose a provider type: | Type | Description | |---|---| | **Python code** | Write `async def` functions; declare tools that reference them | -| **npx package** | Enter an `npx` command (e.g. `npx @playwright/mcp@latest`); the UI auto-introspects the MCP server and populates tool definitions — no code needed | +| **Package** | Enter any command that launches a stdio MCP server (`npx`, `uvx`, `python -m`, or an installed binary); the UI auto-introspects tools — no code needed | After the provider step, the wizard shows a **Secrets** step: any `secrets.env` entries in the provider are listed, and you can fill in their values to save them directly to `.env`. @@ -87,39 +88,24 @@ The **🔑 Secrets** button (also available in the wizard's final step) reads al entries from the selected provider, shows which variables are already set in `.env`, and lets you fill in or update missing values — all without leaving the browser. -### Run Command +### Setup Commands -The **🛠 Run Command** button (in the editor toolbar when a provider is open) lets you run -any shell command inside the server environment — whether that's the local process or inside -the Docker container — and streams the output live in a terminal panel. - -This is particularly useful for npx-based providers that need browser binaries or other -one-time setup steps. For example, after adding a Playwright provider, install the Chrome -browser with: +Each provider has a **Setup Commands** list (editable in the editor panel, saved to YAML). +These shell commands run automatically every time the MCP server starts — perfect for +installing browser binaries, downloading data, or any one-time setup that must survive a +Docker restart. +Example — for a Playwright package provider: ``` npx playwright install chrome ``` -Or install Chromium together with its system dependencies (recommended inside Docker): - -``` -npx playwright install --with-deps chromium -``` - -The modal auto-suggests relevant commands based on the open provider's npx command (e.g. it -pre-fills the playwright install variants when a Playwright provider is selected). Press -**Enter** or click **▶ Run** to stream output; click **⏹ Stop** to kill the process. +Commands run in order before the server accepts connections. The subprocess package is +launched lazily on the first tool call, not at startup, so the browser binary is always +ready when needed. -> **How npx providers start:** the npx process is launched *lazily* — on the first tool call -> after server startup, not at startup itself. This means a `playwright install` run in **Run -> Command** installs the binaries into the server environment; the actual browser process only -> starts when a tool like `browser_navigate` is first called. -> -> When you edit an npx command in the UI and save, the YAML on disk is updated but the running -> process keeps the old command. Click **Restart MCP Server** (the yellow bar that appears -> after saving) to apply the new command — the updated process is then started on the next -> tool call. +> **After editing and saving** a provider's command or setup steps, click **Restart MCP Server** +> (the yellow bar that appears after saving) to apply the changes. ## Secrets @@ -644,14 +630,20 @@ Add `WEATHER_API_KEY=replace-me` to `.env.example` and `.env` (or use the Secret --- -### Part 3 — an npx-based provider (no code required) +### Part 3 — a package provider (no code required) -Use the **+ New Provider → npx package** wizard in the web UI, or create the YAML manually: +Use the **+ New Provider → Package** wizard in the web UI, or create the YAML manually. +Any command that spawns a stdio MCP server works — `npx`, `uvx`, `python -m`, or an +installed binary: ```yaml -npx: +# ── npx (Node.js, no install needed) ───────────────────────────────────────── +package: command: npx @playwright/mcp@latest --headless --isolated +setup_commands: + - npx playwright install chrome # installs browser on every startup + tools: # Populated automatically by the UI's Introspect button — or fill manually - name: browser_navigate @@ -665,20 +657,51 @@ tools: required: [url] ``` +```yaml +# ── uvx (Python package, no install needed) ─────────────────────────────────── +package: + command: uvx mcp-server-fetch + +tools: [] # auto-populated by Introspect +``` + +```yaml +# ── pip-installed Python module ─────────────────────────────────────────────── +package: + command: python -m mcp_server_github + +requirements: + - mcp-server-github # installed by pip before the server starts + +tools: [] +``` + +```yaml +# ── globally installed npm binary ───────────────────────────────────────────── +package: + command: mcp-server-github + +setup_commands: + - npm install -g @modelcontextprotocol/server-github + +tools: [] +``` + > **`--headless`** runs Chromium without a visible window — required inside Docker or any > headless server environment. Remove it if you want to watch the browser on a desktop. > **`--isolated`** gives each session its own browser context (no shared cookies/storage). -The server spawns the `npx` process, performs the MCP handshake once, then forwards -every tool call to it. The process is reused across calls (and started lazily on the -first tool call, not at server startup). +The server spawns the process, performs the MCP handshake once, then forwards every tool +call to it. The process is reused across calls (started lazily on the first tool call). -If the provider requires browser binaries (e.g. Playwright), install them via the -**🛠 Run Command** panel in the web UI before making the first tool call: +### pip Requirements vs setup_commands -``` -npx playwright install chrome -``` +| Feature | Use for | +|---|---| +| `requirements:` | pip packages to install in the Python environment (`httpx`, `requests`, etc.) | +| `setup_commands:` | Any other one-time setup — browser binaries, npm installs, data downloads | + +Both run on every server startup (pip is a no-op if the package is already installed). --- @@ -736,10 +759,24 @@ documentation: | # optional — shown in the web UI; markdown code: | # Python source — executed once at startup # Import anything, define helpers and async tool functions. -# ── npx provider (mutually exclusive with code) ─────────────────────────────── +# ── Package provider (mutually exclusive with code) ─────────────────────────── +# Supports any command: npx, uvx, python -m, or an installed binary. -npx: +package: command: string # e.g. "npx @playwright/mcp@latest --isolated" + # "uvx mcp-server-fetch" + # "python -m mcp_server_github" + # "mcp-server-github" + +# ── Shared optional fields (both provider types) ────────────────────────────── + +requirements: # pip packages installed before the server starts + - package-name + - package-name==1.2.3 + +setup_commands: # shell commands run on every server startup + - npx playwright install chrome # (e.g. browser binaries, npm global installs) + - echo "server ready" # ── Tool declarations (required) ────────────────────────────────────────────── @@ -762,3 +799,4 @@ tools: auth: # optional — forwarded to context["auth"] any_key: any_value ``` + diff --git a/frontend/app.py b/frontend/app.py index d433916..75fe595 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -10,12 +10,11 @@ PUT /api/tools/{name} — update provider from structured JSON DELETE /api/tools/{name} — delete provider YAML POST /api/validate — validate structured provider {provider} -POST /api/introspect-npx — run npx command, return tools list +POST /api/introspect — spawn command, run requirements/setup, return tools list POST /api/extract-functions — parse Python code for async functions 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 -POST /api/run-command — stream a shell command's output as SSE """ import ast @@ -23,7 +22,10 @@ import json import os import re +import shlex import signal +import subprocess +import sys import textwrap import threading import traceback @@ -87,10 +89,33 @@ def _extract_secret_env_keys(spec: dict[str, Any]) -> list[str]: return keys +# --------------------------------------------------------------------------- +# Package manager detection (for logging / display — execution is identical) +# --------------------------------------------------------------------------- + +def _detect_package_manager(command: str) -> str: + """Identify the package manager from the first token of a command string.""" + first = command.strip().split()[0] if command.strip() else "" + if first == "npx": + return "npx" + if first == "uvx": + return "uvx" + if first in ("python", "python3"): + return "pip" + if first == "npm": + return "npm" + return "command" + + # --------------------------------------------------------------------------- # Structured ↔ YAML conversion # --------------------------------------------------------------------------- +def _get_package_spec(spec: dict[str, Any]) -> dict[str, Any] | None: + """Return the subprocess sub-dict (package:), or None for code providers.""" + return spec.get("package") or None + + def _provider_to_structured(name: str, spec: dict[str, Any]) -> dict[str, Any]: """Convert a loaded YAML spec into the structured JSON the UI works with.""" tools_out = [] @@ -119,12 +144,22 @@ def _provider_to_structured(name: str, spec: dict[str, Any]) -> dict[str, Any]: "secrets": secrets, }) + pkg_sub = _get_package_spec(spec) + if pkg_sub is not None: + ptype = "package" + command = (pkg_sub.get("command") or "").strip() + else: + ptype = "code" + command = "" + return { "name": name, "documentation": spec.get("documentation", ""), - "type": "npx" if spec.get("npx") else "code", - "npx_command": (spec.get("npx") or {}).get("command", ""), + "type": ptype, + "command": command, "code": spec.get("code", ""), + "requirements": list(spec.get("requirements") or []), + "setup_commands": list(spec.get("setup_commands") or []), "tools": tools_out, } @@ -139,13 +174,21 @@ def _structured_to_yaml(provider: dict[str, Any]) -> str: ptype = provider.get("type", "code") - if ptype == "npx": - spec["npx"] = {"command": (provider.get("npx_command") or "").strip()} + if ptype == "package": + spec["package"] = {"command": (provider.get("command") or "").strip()} else: code = (provider.get("code") or "").strip() if code: spec["code"] = code + "\n" + requirements = [r for r in (provider.get("requirements") or []) if r] + if requirements: + spec["requirements"] = requirements + + setup_commands = [c for c in (provider.get("setup_commands") or []) if c] + if setup_commands: + spec["setup_commands"] = setup_commands + tools_out = [] for t in provider.get("tools", []): props: dict[str, Any] = {} @@ -188,13 +231,21 @@ def _validate_provider(provider: dict[str, Any]) -> dict[str, Any]: errors: list[str] = [] ptype = provider.get("type", "code") - if ptype == "npx": - if not (provider.get("npx_command") or "").strip(): - errors.append("npx_command is required for npx providers") + if ptype == "package": + if not (provider.get("command") or "").strip(): + errors.append("command is required for package providers") else: if not (provider.get("code") or "").strip(): errors.append("code is required for code providers") + requirements = provider.get("requirements") + if requirements is not None and not isinstance(requirements, list): + errors.append("requirements must be a list") + + setup_commands = provider.get("setup_commands") + if setup_commands is not None and not isinstance(setup_commands, list): + errors.append("setup_commands must be a list") + tools = provider.get("tools", []) if not tools: errors.append("At least one tool is required") @@ -274,12 +325,14 @@ async def list_tools() -> list[dict]: missing_secrets = [k for k in secret_keys if not env_vars.get(k)] structured = _provider_to_structured(path.stem, spec) validation = _validate_provider(structured) + is_package = bool(_get_package_spec(spec)) out.append({ "name": path.stem, "file": path.name, "tool_count": len(tool_entries), "tool_names": [t.get("name") for t in tool_entries], - "is_npx": bool(spec.get("npx")), + "provider_type": structured["type"], + "is_package": is_package, "secret_keys": secret_keys, "missing_secrets": missing_secrets, "validation_errors": validation["errors"], @@ -345,20 +398,52 @@ async def validate_provider(request: Request) -> dict: provider = body.get("provider") or body return _validate_provider(provider) - # ── npx introspection ──────────────────────────────────────────────────── + # ── Package introspection ───────────────────────────────────────────────── + + @app.post("/api/introspect") + async def introspect_package(request: Request) -> dict: + """Introspect any stdio MCP server command. - @app.post("/api/introspect-npx") - async def introspect_npx(request: Request) -> dict: + Accepts: + command — the command to run (required) + requirements — list of pip packages to install first (optional) + setup_commands — list of shell commands to run before spawning (optional) + + Auto-detects the package manager from the command prefix (npx, uvx, + python, etc.) for logging purposes; execution is identical for all. + """ body = await request.json() command = (body.get("command") or "").strip() + requirements: list[str] = body.get("requirements") or [] + setup_commands: list[str] = body.get("setup_commands") or [] if not command: raise HTTPException(400, "command is required") + + pm = _detect_package_manager(command) + try: - from npx_runner import introspect + # 1. Install pip requirements + for req in requirements: + if not req: + continue + subprocess.run( + [sys.executable, "-m", "pip", "install", req], + check=True, + ) + + # 2. Run setup commands + for cmd in setup_commands: + if not cmd: + continue + subprocess.run(shlex.split(cmd), check=True) + + # 3. Introspect the MCP server + from process_runner import introspect tools = await introspect(command) - return {"ok": True, "tools": tools} + return {"ok": True, "tools": tools, "package_manager": pm} except Exception as exc: - return {"ok": False, "error": str(exc), "tools": []} + traceback.print_exc() + return {"ok": False, "error": str(exc), "tools": [], "package_manager": pm} # ── Function extractor (code providers) ────────────────────────────────── @@ -403,50 +488,6 @@ def _send(): threading.Thread(target=_send, daemon=True).start() return {"ok": True} - # ── Command runner (SSE streaming) ──────────────────────────────────────── - - @app.post("/api/run-command") - async def run_command(request: Request) -> StreamingResponse: - body = await request.json() - command = (body.get("command") or "").strip() - if not command: - raise HTTPException(400, "command is required") - - async def _generate(): - proc = None - try: - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - assert proc.stdout is not None - while True: - try: - line = await asyncio.wait_for(proc.stdout.readline(), timeout=300.0) - except asyncio.TimeoutError: - yield f"data: {json.dumps({'error': 'Timed out after 5 min', 'done': True, 'returncode': -1})}\n\n" - proc.kill() - return - if not line: - break - yield f"data: {json.dumps({'line': line.decode(errors='replace').rstrip()})}\n\n" - await proc.wait() - yield f"data: {json.dumps({'done': True, 'returncode': proc.returncode})}\n\n" - except Exception as exc: - if proc is not None: - try: - proc.kill() - except Exception: - pass - yield f"data: {json.dumps({'error': str(exc), 'done': True, 'returncode': -1})}\n\n" - - return StreamingResponse( - _generate(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - # ── HTML UI ─────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -499,12 +540,13 @@ async def index(): /* param rows */ .param-row{display:grid;grid-template-columns:1fr 120px 2fr auto;gap:8px;align-items:start;margin-bottom:6px;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px} .secret-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:center;margin-bottom:6px;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px} +.list-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-bottom:6px} /* CodeMirror */ .CodeMirror{height:260px;font-size:13px;border-radius:0 0 6px 6px;font-family:'JetBrains Mono',Consolas,monospace;border:1px solid #45475a;border-top:none} .cm-wrap{border:1px solid #45475a;border-radius:6px;overflow:hidden} .cm-label{background:#313244;padding:4px 10px;font-size:.75em;color:var(--muted);border:1px solid #45475a;border-bottom:none;border-radius:6px 6px 0 0;font-weight:600;text-transform:uppercase;letter-spacing:.4px} /* badges */ -.badge-npx{background:#cba6f7;color:#1e1e2e;font-size:.65em;padding:2px 6px;border-radius:3px;font-weight:700} +.badge-pkg{background:#cba6f7;color:#1e1e2e;font-size:.65em;padding:2px 6px;border-radius:3px;font-weight:700} .badge-code{background:#89b4fa;color:#1e1e2e;font-size:.65em;padding:2px 6px;border-radius:3px;font-weight:700} .badge-count{background:#45475a;color:#cdd6f4;font-size:.65em;padding:2px 6px;border-radius:3px} /* modal */ @@ -580,7 +622,6 @@ async def index():
httpx, requests==2.32.0)npx playwright install chrome)async def functions — each one becomes an MCP tool
npx command — the server introspects the MCP tools automatically
+ npx, uvx, python -m, or any command — tools are auto-detected
npx playwright install chrome).