⚠️ 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 vianpx,uvx,python -m, or any installed binary - Or a
package:+repository:pair to clone a git repo, run build commands, and spawn the resulting stdio MCP server — useful for servers distributed only as source
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: /app/files, mountable as a Docker volume) — useful for retrieving screenshots
and snapshots produced by package providers such as Playwright MCP.
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.
| Port | Service |
|---|---|
| 8888 | MCP endpoint — http://localhost:8888/mcp |
| 8889 | Web UI & OpenAI-compatible tools endpoint — http://localhost:8889 |
.
├── 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.
Open http://localhost:8889 in your browser after starting the server.
- 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 defsymbols in your code, ortools/listreturned 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
.envvalues for secrets declared in this provider - Delete — remove the provider YAML
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. |
| Repository | Provide a git URL and a list of build commands. mcpproxy clones the repo, runs the build commands, then introspects the resulting stdio MCP server. The URL and build commands are persisted in YAML so the repo can be re-cloned and re-built automatically on every container restart. |
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.
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.
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.
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 nameThe 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:
- Web UI Secrets manager — open
http://localhost:8889, select a provider, click 🔑 Secrets. Values are written to.envautomatically. - Wizard — the final step of the + New Provider wizard lists all required secrets and saves them to
.env. - Manually — copy
.env.exampleto.envand add your values:
cp .env.example .env
# Add entries like: MY_SERVICE_API_KEY=your-value-here- 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_local.shThe script will:
- Generate
.env.examplefrom the YAML tool files if it doesn't exist. - Prompt for any missing or placeholder values and write
.env. - Override
MCP_TOOL_CONFIG_DIRto the correct local path. - Create
.venv, install dependencies, and start the server.
The Web UI and OpenAI-compatible tools endpoint are available at http://localhost:8889; the MCP endpoint is at http://localhost:8888/mcp.
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:latestMinimum 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:latestRun with persistent caches and artefacts — add named volumes so cloned repos, package caches, and provider output files survive container restarts:
docker run -d --rm \
-p 8888:8888 -p 8889:8889 \
--env-file .env \
-v "$(pwd)/tools":/app/tools \
-v mcpproxy-files:/app/files \
-v mcpproxy-repos:/app/repos \
-v mcpproxy-cache:/root/.cache \
-v mcpproxy-npm:/root/.npm \
-v mcpproxy-uv-tools:/root/.local/share/uv \
--name mcpproxy \
ghcr.io/billjr99/mcpproxy:latestEvery volume above is optional — omit any subset and that path falls back to the container's ephemeral writable layer. See Volumes & caching below for what each one covers and the cold-start speedup it provides.
MCP endpoint: http://localhost:8888/mcp
Web UI & OpenAI-compatible tools endpoint: 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:latestTwo things differ from the minimal command:
--env-fileinjects secrets as environment variables at startup; Docker does not expand~inside double quotes, so$HOMEis used instead.-v "$HOME/.mcpproxy/.env:/app/.env"+-e MCP_ENV_FILE=/app/.envalso 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 |
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 downRestart the container to pick up changes to tool YAML files:
docker compose restart mcp-hostOr use the Restart MCP Server button in the web UI.
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 -dcp .env.example .env
# edit .env — set required valuesOr 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.
MCP_HOST_PORT=9000 UI_HOST_PORT=9001 docker compose updocker-compose.yml declares six named volumes. Only the first is required —
the rest persist caches and artefacts that would otherwise be re-downloaded
or re-built on every fresh container.
| Container path | Volume | Holds | Without it (cold start) |
|---|---|---|---|
/app/tools |
mcpproxy-tools |
Provider YAML configs | Required — the proxy has nothing to serve. |
/app/files |
mcpproxy-files |
Provider output artefacts (Playwright screenshots, snapshots, …) surfaced via mcpproxy__listfiles / mcpproxy__getfile |
Files vanish on container removal. |
/app/repos |
mcpproxy-repos |
Cloned git workdirs + their build artefacts (node_modules, dist, …) for repository-mode providers |
Re-clones and re-runs every build_commands on each start (seconds to several minutes per repo). |
/root/.cache |
mcpproxy-cache |
XDG caches: pip wheels, uv wheels, Playwright browser binaries (ms-playwright) |
pip/uvx re-download wheels; npx playwright install chrome re-fetches ~150 MB. |
/root/.npm |
mcpproxy-npm |
npm/npx package cache | npx re-downloads packages from the npm registry on first call. |
/root/.local/share/uv |
mcpproxy-uv-tools |
uvx per-tool venvs | uvx re-creates per-tool venvs from cached wheels. |
In dev (docker-compose.override.yml), mcpproxy-tools, mcpproxy-files, and
mcpproxy-repos are replaced with bind mounts (./tools, ./files, ./repos) so
you can inspect or edit them from the host. The three cache volumes remain named
volumes even in dev — they're opaque package-manager state, not files you read.
For ephemeral / CI runs, drop any subset of volumes — the proxy still works, just slower on the first tool call after each cold start.
The MCP endpoint is http://localhost:8888/mcp (or replace localhost with your
Docker host IP / domain for remote access).
Add the server as a named MCP entry using the HTTP transport:
claude mcp add --transport http mcpproxy http://localhost:8888/mcpOr add it project-locally (stored in .mcp.json in the project root):
claude mcp add --transport http --scope project mcpproxy http://localhost:8888/mcpVerify it is registered:
claude mcp listClaude Code will now list and call your tools automatically during any chat session.
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.
Open Cursor Settings → Features → MCP and add a server entry:
{
"mcpServers": {
"mcpproxy": {
"url": "http://localhost:8888/mcp",
"transport": "http"
}
}
}In VS Code, open the Cline sidebar → MCP Servers tab → Add MCP Server:
- Transport:
HTTP / SSE - URL:
http://localhost:8888/mcp - Name:
mcpproxy
Add to .continue/config.json:
{
"mcpServers": [
{
"name": "mcpproxy",
"transport": {
"type": "http",
"url": "http://localhost:8888/mcp"
}
}
]
}Add to your opencode.json (or ~/.config/opencode/config.json):
{
"mcp": {
"servers": {
"mcpproxy": {
"url": "http://localhost:8888/mcp",
"type": "remote"
}
}
}
}Open Windsurf Settings → Cascade → MCP and add:
{
"mcpServers": {
"mcpproxy": {
"serverUrl": "http://localhost:8888/mcp"
}
}
}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/mcpThen point Zed at that bridge command.
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"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.toolExample 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.
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.shPick 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.shDrives 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?"pip install -r requirements.txt -r requirements-dev.txt
pytest tests/ -vTests 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.
- Do not commit
.env. - Do not enable
debug: trueoutside of local testing. - The web UI has no authentication — run it on a trusted network only.
Every provider is a single YAML file under tools/.
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: []./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"}}}'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}¤t_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_KEYAdd WEATHER_API_KEY=replace-me to .env.example and .env (or use the Secrets manager in the UI).
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 --output-dir /app/files/playwright
setup_commands:
- npx playwright install chrome # installs browser on every startup
# (cached in /root/.cache/ms-playwright via the
# mcpproxy-cache volume — only re-downloads on
# a fresh, unmounted container)
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: []
--headlessruns 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.--isolatedgives 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).
For MCP servers that are published only as source code (no npx / uvx / pip distribution),
use a repository provider. mcpproxy will:
git clonethe repo into a workdir underMCPPROXY_REPOS_DIR(default/app/repos/<provider>).- Run each entry of
build_commandsinside that workdir (e.g.npm install,npm run build). - Spawn the
package.commandfrom inside the workdir and introspect tools the same way as a package provider. - Re-run steps 1–3 on every server start so ephemeral containers always have a fresh build.
- Click + New Provider → choose 📂 Repository.
- Fill in:
- Provider name — e.g.
linkedin. - Git URL —
https://github.com/felipfr/linkedin-mcpserver(https or ssh). - Ref (optional) — branch, tag, or commit SHA. Defaults to the repo's default branch.
- Build commands — one per row. For most Node/TypeScript MCP repos:
npm install, thennpm run build. Click ⚡ Pre-fill Node/TS to drop these in automatically along with the spawn command. - Spawn command — the stdio MCP launch command. For the compiled-TS pattern above, use
node build/index.js(thenpm run buildstep compilessrc/*.ts→build/*.js). Runs inside the workdir.
- Provider name — e.g.
- Click Next — mcpproxy clones, builds, and introspects. The tool list is auto-populated.
Recommended for Node/TypeScript repos (covers
linkedin-mcpserverand most fastmcp-style projects):
Field Value Build commands npm installnpm run buildSpawn command node build/index.jsThe ⚡ Pre-fill Node/TS button in the wizard's Build commands header populates all three at once.
Do not put
npm run start:dev,npm start, or any other long-running server command in Build commands — those go in Spawn command. Build commands must terminate; mcpproxy enforces aMCPPROXY_BUILD_TIMEOUT(default 600s) and aborts a hanging build.
package:
command: node build/index.js # spawn command, run inside the workdir
repository:
url: https://github.com/felipfr/linkedin-mcpserver
ref: main # optional
workdir: /app/repos/linkedin # optional — defaults to <REPOS_DIR>/<provider>
build_commands:
- npm install
- npm run build
env_keys: # auto-discovered from .env.example
- LINKEDIN_CLIENT_ID # values live in MCP_ENV_FILE
- LINKEDIN_CLIENT_SECRET # (the proxy's .env) and are written
# into <workdir>/.env on every build / spawn
tools:
- name: search_jobs # advertised as linkedin__search_jobs
description: Search LinkedIn job postings.
input_schema:
type: object
properties:
query: {type: string, description: "Search query"}
required: [query]The package.command is what spawns the MCP server (just like a regular package provider).
The new repository: block tells the server how to materialize the workdir on startup.
If the cloned repo contains a .env.example (or .env.sample / .env.template)
at its root, mcpproxy parses it after the clone step and surfaces every
KEY= line in two places:
- The wizard's Secrets step (so you can fill in values immediately).
- The provider's
repository.env_keyslist in YAML (editable in the 📂 Repository editor box).
Values themselves live in MCP_ENV_FILE (the proxy's .env) — the same
storage every other secret uses. At spawn time and on every restart, the
server:
- Reads the current values from
MCP_ENV_FILEand the process environment. - Writes a
.envfile inside<workdir>containing only the keys that are actually set (empty / unset keys are skipped). - Passes the same values as environment variables to the spawned MCP subprocess.
This covers both server styles: code that calls dotenv.config() /
tsx --env-file=.env reads the on-disk file, while code that reads
process.env.X / os.environ[X] sees the env vars directly.
A common failure mode: a build command like npm install triggers a
postinstall script that requires secrets, but the user hasn't filled
them in yet. mcpproxy's wizard handles this gracefully:
- The clone step runs first, then
.env.exampleis parsed. - If a build command then fails, the wizard surfaces the error inline and still continues to the Secrets step with the discovered keys.
- After you fill in the secrets and save,
materialize_repositoryre-runs the build on the next server start — with<workdir>/.envnow populated — and the build succeeds.
The editor shows a 📂 Repository box with the git URL, ref, build commands, and the auto-discovered env keys list.
- ↻ Re-clone & build — re-runs
git pull(orgit cloneon a fresh container) and the build commands, then re-introspects the spawn command. Newly-discovered env keys are merged into the list. - ↻ Re-scan on the env keys row — re-parses
.env.examplewithout re-running the build (useful if you've just pulled a new commit that adds variables). - After saving, click Restart MCP Server to apply changes — on
startup the server walks every repository provider, re-clones / pulls
/ re-builds, writes
<workdir>/.env, then registers tools.
| Variable | Default | Description |
|---|---|---|
MCPPROXY_REPOS_DIR |
/app/repos |
Base directory for cloned repos. |
The default docker-compose.yml mounts the mcpproxy-repos named volume here
(or ./repos in dev via the override file) so cloned trees and their build
artefacts (node_modules, dist, …) survive container restarts. See
Volumes & caching for the full list.
Drop the volume entry for ephemeral / disposable containers — every container start will re-clone and re-build into the container's writable layer.
On every server start, server.py walks each YAML provider and:
- If the spec has a
repository:block, runsgit clone(orgit pullif the workdir already contains.git), then re-runs every entry inbuild_commandswithcwd=<workdir>. - Then runs the standard
requirements:(pip) andsetup_commands:lists. - Then registers the tools and spawns the MCP subprocess (lazily, on first tool call).
- Build commands run as the server user with full shell-style splitting via
shlex.split. Do not paste untrusted commands. - The git URL is passed directly to
git clone. Private repos require SSH keys or a credential helper to be configured inside the container.
| Symptom | What to check |
|---|---|
| Clone hangs or fails | The container must have outbound HTTPS / SSH to the git host. For SSH, mount your ~/.ssh and configure known_hosts. |
npm install / build fails |
View container stdout: docker compose logs -f. All build output is streamed unbuffered. |
| Spawn / introspect fails | The repo must produce a working stdio MCP server. Check the spawn command resolves inside the workdir (e.g. dist/main.js only exists after a successful build). |
| Tools not appearing after edit | Click Restart MCP Server so the YAML is re-loaded and the workdir re-materialized. |
| 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).
A single YAML file can declare any number of tools sharing the same code block.
Return {"ok": True, ...} on success, {"ok": False, "error": "..."} on failure. Never let an exception propagate — wrap the entire function body in try/except.
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)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.",
)Write state to a well-known file path and read it on the next call.
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: /app/files inside Docker (mounted as the
mcpproxy-files named volume, or ./files in dev — see
Volumes & caching). Override with the MCPPROXY_FILES_DIR
environment variable. run_local.sh automatically sets it to ./files under the
repo root when running outside Docker.
Each package provider should write its artefacts under its own subdirectory of
the base — e.g. Playwright is launched with
npx @playwright/mcp@latest … --output-dir /app/files/playwright so screenshots
land at /app/files/playwright/screenshot.png.
Note (migrating from earlier versions): the default was previously
.playwright-mcp(relative to the cwd, i.e./app/.playwright-mcpinside Docker). If you have a customtools/playwright.yaml, either add the--output-dir /app/files/playwrightflag to its spawn command, or setMCPPROXY_FILES_DIR=/app/.playwright-mcpto keep the old layout.
Only files inside the base directory are accessible — path-traversal attempts
(../) are rejected.
- Ask the LLM to navigate to a page and take a screenshot via the Playwright MCP provider.
- Playwright writes
screenshot.pngto/app/files/playwright/(because its spawn command includes--output-dir /app/files/playwright). - Ask the LLM to call
mcpproxy__listfileswithpath="playwright"— it returns the file list. - Ask the LLM to call
mcpproxy__getfilewithpath="playwright/screenshot.png"— it returns the PNG as a base64 string that the LLM can describe or pass to a vision model.
| 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}).
| 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.
# In docker-compose.override.yml or as -e flag
MCPPROXY_FILES_DIR=/app/dataOr mount a different volume / host directory at the target path:
volumes:
- ./playwright-output:/app/files # bind-mount host dir at the default locationBy default docker-compose.yml mounts the named volume mcpproxy-files at
/app/files, and docker-compose.override.yml swaps that for ./files in dev.
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 --output-dir /app/files/playwright"
# "uvx mcp-server-fetch"
# "python -m mcp_server_github"
# "mcp-server-github"
# ── Repository provider (clone + build, spawned from inside the workdir) ──────
# When `repository:` is present, the `package.command` above is run with cwd
# set to the cloned workdir. Clone + build re-runs on every server start.
repository:
url: string # e.g. "https://github.com/owner/repo"
ref: string # optional — branch, tag, or commit SHA
workdir: string # optional — defaults to <MCPPROXY_REPOS_DIR>/<provider>
build_commands: # shell commands run in <workdir> before spawn
- npm install
- npm run build
env_keys: # optional — KEY names whose values live
- MY_API_KEY # in MCP_ENV_FILE. A .env file is written
- SECRET_TOKEN # into <workdir> before every build / spawn.
# Auto-discovered from .env.example.
# ── Shared optional fields (all 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