Skip to content

BillJr99/mcpproxy

Repository files navigation

mcpproxy: Config-Driven MCP Host

⚠️ Disclaimer: This software is experimental and provided as-is, with no guarantees of security, stability, or fitness for any particular purpose. It has not undergone a security audit. Do not expose it to untrusted networks or use it to handle sensitive data in production. See LICENSE for the full MIT license terms.

A Dockerized, config-driven MCP server with a built-in web UI.
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 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, 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.

Two built-in tools (mcpproxy__listfiles and mcpproxy__getfile) are always registered without any YAML config. They give LLMs read-only access to a configurable directory (default: .playwright-mcp) — useful for retrieving screenshots and snapshots produced by package providers such as Playwright MCP.

Tool names advertised to the LLM

Every tool is advertised to MCP clients as <provider>__<tool> — the provider name (the YAML filename without the .yaml extension, normalized to [a-zA-Z0-9-]) joined to the tool's own name by a double underscore. For example, a YAML file tools/playwright.yaml declaring a tool browser_navigate is exposed to the LLM as playwright__browser_navigate. This guarantees that tools from different providers cannot collide, even if they happen to share a name.

The two built-in tools follow the same convention: mcpproxy__listfiles and mcpproxy__getfile. The name: field in your YAML stays unprefixed — the prefix is added automatically when the tool is registered.

Ports

Port Service
8888 MCP endpoint — http://localhost:8888/mcp
8889 Web UI — http://localhost:8889

Layout

.
├── Dockerfile
├── docker-compose.yml              ← base: named volumes (prod/CI)
├── docker-compose.override.yml     ← dev: bind mounts (auto-merged locally)
├── run_local.sh                    ← interactive local setup + launch
├── requirements.txt
├── requirements-dev.txt            ← test dependencies
├── server.py
├── config.py                       ← shared env-var config (imported by all modules)
├── process_runner.py               ← spawns & proxies any stdio MCP subprocess
├── builtin_tools.py                ← built-in mcpproxy__listfiles / mcpproxy__getfile tools
├── frontend/
│   └── app.py                      ← FastAPI UI server (port 8889)
├── .env.example
├── handlers/
│   └── elicitation.py              ← shared mid-call input helper
├── tests/
│   ├── conftest.py
│   ├── test_server.py
│   ├── test_frontend.py
│   ├── test_with_ollama.sh         ← quick end-to-end MCP + Ollama sanity check
│   ├── mcp_interactive.sh          ← interactive tool picker & tester
│   └── ollama_agent.py             ← agentic tool-calling loop (Python)
└── tools/                          ← gitignored; mount at runtime
    └── <your-provider>.yaml        ← provider: code + tool declarations

tools/ is gitignored — it is never committed and is mounted into the container at runtime.

Web UI

Open http://localhost:8889 in your browser after starting the server.

Tools tab

  • Browse all loaded providers (left panel)
  • Click any provider to open its fields in a form editor
  • Edit documentation, code, and per-tool fields (name, description, parameters)
  • Add or remove tools with the + Add Tool / buttons
  • Enable / disable individual tools with the switch in each tool card's header. A disabled tool is kept in YAML (as enabled: false) but not registered with MCP and not shown to the LLM — toggle it back on later without re-typing the schema.
  • Function / Tool-name menu — when mcpproxy can discover the legal set of names (async def symbols in your code, or tools/list returned by a package's stdio server), the field becomes a dropdown plus an Other… option so you can pick from the menu or type a custom value. Discovery runs automatically when you open a provider, when the code changes, and when the package command field loses focus; the ↻ Re-scan button forces a refresh. Failure is silent — the dropdown just falls back to "Other…" so you can always free-type.
  • Save — write the file; restart MCP server to reload
  • 🔑 Secrets — manage .env values for secrets declared in this provider
  • Delete — remove the provider YAML

New Provider wizard

Click + New Provider and choose a provider type:

Type Description
Python code Write async def functions; the UI lists the ones it finds as you type. Each becomes a tool entry.
Package Enter any command that launches a stdio MCP server (npx, uvx, python -m, or an installed binary). When you click Next, mcpproxy auto-introspects the command and pre-populates the tool list; if introspection fails you can still proceed and add tools by hand.

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.

Secrets manager

The 🔑 Secrets button (also available in the wizard's final step) reads all secrets.env 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.

Setup Commands

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

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.

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

Each tool provider YAML declares its required environment variables under secrets.env:

tools:
  - name: my_tool
    ...
    secrets:
      env:
        api_key: MY_SERVICE_API_KEY   # handler arg → env var name

The server injects the value of MY_SERVICE_API_KEY from the environment at call time. The LLM never sees the value — it is not in the tool schema.

Ways to set secret values:

  1. Web UI Secrets manager — open http://localhost:8889, select a provider, click 🔑 Secrets. Values are written to .env automatically.
  2. Wizard — the final step of the + New Provider wizard lists all required secrets and saves them to .env.
  3. Manually — copy .env.example to .env and add your values:
cp .env.example .env
# Add entries like: MY_SERVICE_API_KEY=your-value-here
  1. run_local.sh — prompts for all missing values and writes .env.

The .env file is consumed by Docker Compose via env_file. Credentials are never part of the MCP tool schema, so they are not exposed as LLM-visible tool arguments. Do not commit .env.

Run locally

./run_local.sh

The script will:

  1. Generate .env.example from the YAML tool files if it doesn't exist.
  2. Prompt for any missing or placeholder values and write .env.
  3. Override MCP_TOOL_CONFIG_DIR to the correct local path.
  4. Create .venv, install dependencies, and start the server.

The UI is available at http://localhost:8889 and the MCP endpoint at http://localhost:8888/mcp.

Run with Docker

Pull and run the pre-built image from GHCR

Every push to main publishes a fresh image to the GitHub Container Registry. You don't need to clone the repo or build anything.

docker pull ghcr.io/billjr99/mcpproxy:latest

Minimum run command — bind-mount your tools/ directory and pass secrets via an env file. handlers/ is baked into the image; no mount needed.

docker run -d --rm \
  -p 8888:8888 -p 8889:8889 \
  --env-file .env \
  -v "$(pwd)/tools":/app/tools \
  --name mcpproxy \
  ghcr.io/billjr99/mcpproxy:latest

MCP endpoint: http://localhost:8888/mcp
Web UI: http://localhost:8889

The -d flag runs the container as a daemon and returns you to the shell immediately. Follow logs with docker logs -f mcpproxy; stop the container with docker stop mcpproxy.

Note: tools/ is never baked into the image and must be supplied at runtime via a volume mount. handlers/ is part of the image — no mount required.

Run from a persistent home directory — store tools and secrets in ~/.mcpproxy so you can run the image from any working directory and the web UI can read and write .env:

# First time only — create the directory and an empty .env
mkdir -p ~/.mcpproxy/tools
touch ~/.mcpproxy/.env

docker run -d \
  -p 8888:8888 -p 8889:8889 \
  --env-file "$HOME/.mcpproxy/.env" \
  -e MCP_ENV_FILE=/app/.env \
  -v "$HOME/.mcpproxy/tools:/app/tools" \
  -v "$HOME/.mcpproxy/.env:/app/.env" \
  --name mcpproxy \
  ghcr.io/billjr99/mcpproxy:latest

Two things differ from the minimal command:

  • --env-file injects secrets as environment variables at startup; Docker does not expand ~ inside double quotes, so $HOME is used instead.
  • -v "$HOME/.mcpproxy/.env:/app/.env" + -e MCP_ENV_FILE=/app/.env also mount the file itself into the container so the web UI's 🔑 Secrets panel can read and write values without leaving the browser.

Available tags:

Tag When updated
latest Every push to main
main Every push to main
vX.Y.Z On a version tag
sha-<short> Per-commit SHA

Local development (bind mounts)

docker-compose.override.yml is merged automatically when you run docker compose without a -f flag:

# First run: build and start
docker compose up --build

# Subsequent runs
docker compose up

# Run in the background
docker compose up -d

# Follow logs
docker compose logs -f

# Stop
docker compose down

Restart the container to pick up changes to tool YAML files:

docker compose restart mcp-host

Or use the Restart MCP Server button in the web UI.

Production / CI (named volumes)

Populate the tools volume once before the first run:

docker run --rm \
  -v mcpproxy-tools:/dst \
  -v "$(pwd)/tools":/src:ro \
  alpine sh -c "cp -r /src/. /dst/"

Then start with only the base file:

docker compose -f docker-compose.yml up -d

Environment variables and secrets

cp .env.example .env
# edit .env — set required values

Or use the web UI's Secrets manager at http://localhost:8889.

Docker Compose reads .env via env_file:. The file is never copied into the image. Do not commit .env.

Custom ports

MCP_HOST_PORT=9000 UI_HOST_PORT=9001 docker compose up

Connecting AI clients to this MCP server

The MCP endpoint is http://localhost:8888/mcp (or replace localhost with your Docker host IP / domain for remote access).

Claude Code (Anthropic CLI)

Add the server as a named MCP entry using the HTTP transport:

claude mcp add --transport http mcpproxy http://localhost:8888/mcp

Or add it project-locally (stored in .mcp.json in the project root):

claude mcp add --transport http --scope project mcpproxy http://localhost:8888/mcp

Verify it is registered:

claude mcp list

Claude Code will now list and call your tools automatically during any chat session.

Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "mcpproxy": {
      "url": "http://localhost:8888/mcp",
      "transport": "http"
    }
  }
}

Restart Claude Desktop — your tools appear in the tools panel.

Cursor

Open Cursor Settings → Features → MCP and add a server entry:

{
  "mcpServers": {
    "mcpproxy": {
      "url": "http://localhost:8888/mcp",
      "transport": "http"
    }
  }
}

Cline (VS Code extension)

In VS Code, open the Cline sidebar → MCP Servers tab → Add MCP Server:

  • Transport: HTTP / SSE
  • URL: http://localhost:8888/mcp
  • Name: mcpproxy

Continue (VS Code / JetBrains extension)

Add to .continue/config.json:

{
  "mcpServers": [
    {
      "name": "mcpproxy",
      "transport": {
        "type": "http",
        "url": "http://localhost:8888/mcp"
      }
    }
  ]
}

OpenCode

Add to your opencode.json (or ~/.config/opencode/config.json):

{
  "mcp": {
    "servers": {
      "mcpproxy": {
        "url": "http://localhost:8888/mcp",
        "type": "remote"
      }
    }
  }
}

Windsurf

Open Windsurf Settings → Cascade → MCP and add:

{
  "mcpServers": {
    "mcpproxy": {
      "serverUrl": "http://localhost:8888/mcp"
    }
  }
}

Zed

In ~/.config/zed/settings.json:

{
  "context_servers": {
    "mcpproxy": {
      "command": {
        "path": "npx",
        "args": ["-y", "@modelcontextprotocol/server-fetch"],
        "env": {}
      }
    }
  }
}

Note: Zed currently supports stdio-based MCP servers natively. For HTTP-transport servers, use an MCP-to-stdio bridge such as mcp-remote:

npx -y mcp-remote http://localhost:8888/mcp

Then point Zed at that bridge command.

Ollama (tool-calling models)

Ollama itself does not speak MCP — use the included tests/ollama_agent.py script, which bridges MCP → Ollama tool-calling automatically:

python3 tests/ollama_agent.py "List the tools you have available"

The script queries http://localhost:11434/api/tags for available models, shows a numbered selection menu, then drives a full agentic tool-calling loop.

Override defaults with environment variables:

OLLAMA_BASE=http://mymachine:11434 \
OLLAMA_MODEL=qwen3:14b \
MCP_BASE=http://localhost:8888/mcp \
python3 tests/ollama_agent.py "Do something useful"

Models without native MCP support (Pi, Hermes, GPT-4o, etc.)

For any model that does not support MCP natively, you can describe the available tools in the system prompt or at the start of a conversation. List the MCP endpoint and paste in the JSON schema from tools/list:

# Fetch the tool schemas
curl -s http://localhost:8888/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  | python3 -m json.tool

Example system prompt snippet:

You have access to the following tools via an MCP server at http://localhost:8888/mcp.
To call a tool, output a JSON block with the tool name and arguments; I will execute
the call and paste the result back.

Tools:
<paste tools/list output here>

Then manually relay tool calls and results between the model and the MCP server during the conversation.


Test scripts

tests/test_with_ollama.sh — quick sanity check

Runs MCP initialize → tools/list (and optionally tools/call) and asks Ollama to summarise the results.

bash tests/test_with_ollama.sh

# Override defaults
MCP_URL=http://localhost:8888/mcp \
OLLAMA_MODEL=qwen3:14b \
RUN_REAL_TOOL=1 \
bash tests/test_with_ollama.sh

tests/mcp_interactive.sh — interactive tool tester

Pick any registered tool, get prompted for parameters, call the tool, and get an Ollama summary of the result. Secrets are checked for presence only — values are never printed.

bash tests/mcp_interactive.sh

# Override defaults
MCP_URL=http://localhost:8888/mcp \
UI_URL=http://localhost:8889 \
OLLAMA_URL=http://localhost:11434 \
bash tests/mcp_interactive.sh

tests/ollama_agent.py — agentic loop

Drives a full agentic tool-calling loop: MCP initialize → tools/list → Ollama chat with tool schemas → execute tool_calls → feed results back → repeat until a final text answer.

python3 tests/ollama_agent.py "Go to https://example.com and summarise the page"

# Override defaults
OLLAMA_BASE=http://localhost:11434 \
OLLAMA_MODEL=llama3.2 \
MCP_BASE=http://localhost:8888/mcp \
python3 tests/ollama_agent.py "What tools do you have?"

Running unit tests

pip install -r requirements.txt -r requirements-dev.txt
pytest tests/ -v

Tests cover server.py (pure helpers), frontend/app.py (all API endpoints), and builtin_tools.py (file listing and retrieval). CI runs on every push via .github/workflows/tests.yml.


Security notes

  • Do not commit .env.
  • Do not enable debug: true outside of local testing.
  • The web UI has no authentication — run it on a trusted network only.

Tutorial: adding a new tool

Every provider is a single YAML file under tools/.

Part 1 — a simple tool with no secrets

Step 1 — create tools/ping.yaml

code: |
  import datetime
  from typing import Any

  async def ping(context: dict[str, Any], message: str = "hello") -> dict[str, Any]:
      return {
          "ok": True,
          "echo": message,
          "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
      }

tools:
  - name: ping
    function: ping
    description: Echo a message back with a server-side UTC timestamp.
    input_schema:
      type: object
      properties:
        message:
          type: string
          default: "hello"
          description: The text to echo back.
      required: []

Step 2 — restart and test

./run_local.sh
# The provider file is tools/ping.yaml, so the advertised tool name is "ping__ping".
curl -s -X POST http://localhost:8888/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping__ping","arguments":{"message":"world"}}}'

Part 2 — a tool with injected secrets

code: |
  import urllib.request, json, traceback
  from typing import Any

  async def get_weather(context, latitude, longitude, api_key):
      try:
          url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&current_weather=true"
          with urllib.request.urlopen(url, timeout=10) as r:
              data = json.loads(r.read())
          return {"ok": True, **data.get("current_weather", {})}
      except Exception as e:
          traceback.print_exc()
          return {"ok": False, "error": str(e)}

tools:
  - name: get_weather
    function: get_weather
    description: Return current weather at a coordinate.
    input_schema:
      type: object
      properties:
        latitude:
          type: number
        longitude:
          type: number
      required: [latitude, longitude]
    secrets:
      env:
        api_key: WEATHER_API_KEY

Add WEATHER_API_KEY=replace-me to .env.example and .env (or use the Secrets manager in the UI).


Part 3 — a package provider (no code required)

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:

# ── 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 when the wizard introspects the command — or fill manually
  - name: browser_navigate                # advertised as playwright__browser_navigate
    description: Navigate to a URL in a browser.
    input_schema:
      type: object
      properties:
        url:
          type: string
          description: The URL to navigate to.
      required: [url]
# ── uvx (Python package, no install needed) ───────────────────────────────────
package:
  command: uvx mcp-server-fetch

tools: []   # auto-populated by the wizard's introspection step
# ── pip-installed Python module ───────────────────────────────────────────────
package:
  command: python -m mcp_server_github

requirements:
  - mcp-server-github   # installed by pip before the server starts

tools: []
# ── 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 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).

pip Requirements vs setup_commands

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).


Part 4 — multiple tools in one provider

A single YAML file can declare any number of tools sharing the same code block.


Part 5 — error handling

Return {"ok": True, ...} on success, {"ok": False, "error": "..."} on failure. Never let an exception propagate — wrap the entire function body in try/except.


Part 6 — calling blocking libraries with asyncio.to_thread

Handler functions are async, but many Python libraries block the event loop. Use asyncio.to_thread() to run them safely in a thread pool.

result = await asyncio.to_thread(_fetch_sync, arg1, arg2)

Part 7 — prompting the user mid-call (elicitation)

from handlers.elicitation import request_text_input_with_fallback

sms_result = await request_text_input_with_fallback(
    context=context,
    field_name="sms_code",
    message="We sent an SMS to your phone.",
    description="Enter the six-digit code.",
)

Part 8 — persisting state between calls

Write state to a well-known file path and read it on the next call.


Part 9 — reading files produced by package providers

Package providers (e.g. Playwright MCP) often write files to disk — screenshots (PNG), accessibility snapshots (JSON), downloaded pages (HTML) — that the LLM would otherwise have no way to retrieve.

mcpproxy ships two built-in utility tools that are always registered, with no YAML config file required:

Tool Description
mcpproxy__listfiles List files and subdirectories inside the files base directory
mcpproxy__getfile Read a file from the files base directory (UTF-8 text or base64)

Default base directory: .playwright-mcp relative to the server's working directory (i.e. /app/.playwright-mcp inside Docker). Override with the MCPPROXY_FILES_DIR environment variable.

Only files inside the base directory are accessible — path-traversal attempts (../) are rejected.

Example workflow with Playwright

  1. Ask the LLM to navigate to a page and take a screenshot via the Playwright MCP provider.
  2. Playwright writes screenshot.png to .playwright-mcp/.
  3. Ask the LLM to call mcpproxy__listfiles — it returns the file list.
  4. Ask the LLM to call mcpproxy__getfile with path="screenshot.png" — it returns the PNG as a base64 string that the LLM can describe or pass to a vision model.

mcpproxy__listfiles parameters

Parameter Type Required Default Description
path string No "" Subdirectory to list, relative to the base dir. Omit to list the root.

Returns an object with ok, base_dir, path, and entries (list of {name, type, size}).

mcpproxy__getfile parameters

Parameter Type Required Default Description
path string Yes File path, relative to the base dir.
encoding string No "auto" "auto" tries UTF-8, falls back to base64. "text" forces UTF-8. "base64" always base64.

Returns an object with ok, path, size, content, and encoding.

Changing the base directory

# In docker-compose.override.yml or as -e flag
MCPPROXY_FILES_DIR=/app/data

Or mount a volume at the target path so files persist across container restarts:

volumes:
  - ./playwright-output:/app/.playwright-mcp

YAML provider reference

documentation: |                   # optional — shown in the web UI; markdown friendly
  Describe what this provider does, its tools, secrets, and any usage notes.

# ── Python code provider ──────────────────────────────────────────────────────

code: |                            # Python source — executed once at startup
  # Import anything, define helpers and async tool functions.

# ── Package provider (mutually exclusive with code) ───────────────────────────
# Supports any command: npx, uvx, python -m, or an installed binary.

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) ──────────────────────────────────────────────

tools:
  - name: string                   # tool name as written here; the LLM sees
                                   # "<provider>__<name>" (e.g. playwright__browser_navigate)
    function: string               # async function name from code block (code providers only)
    description: string            # shown to the LLM
    enabled: true                  # optional (default true); set false to keep the tool
                                   # in YAML but not advertise / register it
    documentation: string          # optional per-tool notes shown in the web UI
    input_schema:                  # JSON Schema
      type: object
      properties:
        arg_name:
          type: string|number|integer|boolean|array|object
          description: string
          default: any
      required: [arg_name]
    secrets:
      env:                         # optional
        handler_arg: ENV_VAR_NAME
    auth:                          # optional — forwarded to context["auth"]
      any_key: any_value

About

A lightweight proxy server that aggregates and routes Model Context Protocol (MCP) traffic across multiple backend MCP servers, presenting a unified interface to MCP-compatible clients.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors