Proxy Deployment Guide
This guide covers everything you need to deploy the governance proxy in development and production: the complete CLI reference, YAML and JSON config file formats, Docker packaging, and docker-compose orchestration.
If you haven't used the proxy yet, start with the Proxy Quickstart.
Prerequisites
- Python 3.10+
pip install waxell-observe[mcp-server]-- installs the proxy, FastMCP, and PyYAML- A Waxell API key (optional but recommended) -- for controlplane policy checks
CLI Reference
The proxy runs via the wax CLI:
wax observe mcp-proxy [OPTIONS]
Options
| Option | Short | Default | Description |
|---|---|---|---|
--config | -c | -- | Path to a YAML or JSON config file |
--target | -t | -- | Target server URL or path (quick-start mode) |
--transport | stdio | Inbound transport: stdio or http | |
--port | 8080 | Port for HTTP transport | |
--host | 0.0.0.0 | Host for HTTP transport | |
--api-key | $WAXELL_API_KEY | Waxell API key for controlplane policy checks | |
--api-url | $WAXELL_API_URL | Waxell API URL | |
--name | waxell-proxy | Proxy server name (used in spans and metadata) | |
--no-fingerprint | Disable tool fingerprinting | ||
--no-rug-pull-detection | Disable rug pull detection |
Either --config or --target is required. If both are provided, --config takes precedence.
Usage Examples
# Quick-start: wrap a remote HTTP server
wax observe mcp-proxy --target http://remote-server:9000/mcp
# Quick-start: wrap a local script
wax observe mcp-proxy --target ./my_server.py
# Config file mode
wax observe mcp-proxy --config proxy.yaml
# HTTP transport with custom port
wax observe mcp-proxy --config proxy.yaml --transport http --port 9090
# Override API key and disable security features
wax observe mcp-proxy --config proxy.yaml \
--api-key wax_sk_... \
--no-fingerprint \
--no-rug-pull-detection
# Name the proxy for easier trace identification
wax observe mcp-proxy --target http://composio.example.com/mcp --name composio-proxy
CLI Flag Overrides
When using --config, CLI flags override the corresponding config file values. This lets you use a shared config file across environments while varying settings per deployment:
# Use production config, override transport and port for local testing
wax observe mcp-proxy --config proxy.yaml --transport http --port 3001
Config File Formats
The proxy accepts YAML (.yaml, .yml) or JSON (.json) config files. Both formats support environment variable substitution.
YAML Config
# proxy.yaml
target:
url: "http://remote-server:9000/mcp"
name: "acme-governance-proxy"
transport: stdio
# Governance
api_key: "${WAXELL_API_KEY}"
api_url: "${WAXELL_API_URL}"
default_pii_scan: standard
default_policy_action: allow
fail_open: true
# Per-tool policies
tools:
send_email:
pii_scan: strict
approval_required: true
rate_limit:
max_per_minute: 10
max_per_hour: 100
delete_records:
policy_action: block
# Security
fingerprinting: true
rug_pull_detection: true
JSON Config
{
"target": {
"url": "http://remote-server:9000/mcp"
},
"name": "acme-governance-proxy",
"transport": "stdio",
"api_key": "${WAXELL_API_KEY}",
"api_url": "${WAXELL_API_URL}",
"default_pii_scan": "standard",
"default_policy_action": "allow",
"fail_open": true,
"tools": {
"send_email": {
"pii_scan": "strict",
"approval_required": true,
"rate_limit": {
"max_per_minute": 10,
"max_per_hour": 100
}
},
"delete_records": {
"policy_action": "block"
}
},
"fingerprinting": true,
"rug_pull_detection": true
}
YAML is recommended for human-edited configs (cleaner syntax, supports comments). JSON is better for machine-generated configs or when you want to avoid the pyyaml dependency.
JSON configs do not require pyyaml -- only the json stdlib module is used.
Environment Variable Substitution
Config files support ${VAR_NAME} syntax for environment variable references. Variables are resolved via os.path.expandvars() before parsing, so any environment variable available to the process can be referenced:
# These are resolved at load time
api_key: "${WAXELL_API_KEY}"
api_url: "${WAXELL_API_URL}"
target:
url: "${MCP_TARGET_URL}"
name: "${PROXY_NAME:-waxell-proxy}" # shell default syntax works too
If an environment variable is not set, ${VAR_NAME} is left as the literal string $VAR_NAME (without braces). This will likely cause a validation error when the proxy starts. Always ensure referenced variables are set before starting the proxy.
Target Formats
The target field supports three connection formats:
HTTP Target (remote server)
target:
url: "http://remote-server:9000/mcp"
Use this for remote MCP servers that expose an HTTP endpoint (streamable HTTP or SSE).
Command Target (stdio subprocess)
target:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/allowed"]
env:
NODE_ENV: "production"
Use this for MCP servers that communicate over stdio. The proxy spawns the command as a child process. The optional env dict sets additional environment variables for the subprocess.
Path Target (local script)
target:
path: "./my_server.py"
Use this for local Python or JavaScript MCP server scripts. FastMCP auto-detects the runtime from the file extension.
Docker Deployment
Package the proxy as a Docker image for consistent, reproducible deployments.
Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install the proxy with MCP server support
RUN pip install --no-cache-dir "waxell-observe[mcp-server]"
# Copy config file
COPY proxy.yaml .
# The proxy runs on stdio by default.
# Override with --transport http for networked deployments.
ENTRYPOINT ["wax", "observe", "mcp-proxy"]
CMD ["--config", "proxy.yaml", "--transport", "http", "--port", "8080"]
Build and run:
docker build -t waxell-proxy .
docker run -p 8080:8080 \
-e WAXELL_API_KEY="wax_sk_..." \
-e WAXELL_API_URL="https://acme.waxell.dev" \
waxell-proxy
Use --transport http when running in Docker so that other containers (or external clients) can reach the proxy over the network. The stdio transport only works when the proxy and client share a process boundary.
Multi-Stage Build
For smaller images, use a multi-stage build:
FROM python:3.12-slim AS builder
RUN pip install --no-cache-dir "waxell-observe[mcp-server]"
FROM python:3.12-slim
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin/wax /usr/local/bin/wax
WORKDIR /app
COPY proxy.yaml .
ENTRYPOINT ["wax", "observe", "mcp-proxy"]
CMD ["--config", "proxy.yaml", "--transport", "http", "--port", "8080"]
docker-compose Deployment
Use docker-compose to run the proxy alongside the target MCP server. This is the recommended approach for local development and testing.
Proxy + HTTP Target
# docker-compose.yaml
services:
# The target MCP server
mcp-server:
image: your-mcp-server:latest
ports:
- "9000:9000"
# The governance proxy
proxy:
build: .
ports:
- "8080:8080"
environment:
- WAXELL_API_KEY=${WAXELL_API_KEY}
- WAXELL_API_URL=${WAXELL_API_URL}
- MCP_TARGET_URL=http://mcp-server:9000/mcp
depends_on:
- mcp-server
With a proxy.yaml that references the target via environment variable:
target:
url: "${MCP_TARGET_URL}"
name: "governed-proxy"
transport: http
port: 8080
api_key: "${WAXELL_API_KEY}"
api_url: "${WAXELL_API_URL}"
default_pii_scan: standard
fail_open: true
Start both services:
docker-compose up
In docker-compose, services communicate using their service names as hostnames. The proxy reaches the target at http://mcp-server:9000/mcp because both services are on the same default network. No special networking configuration is needed.
Proxy + Stdio Target (sidecar pattern)
For MCP servers that use stdio transport, run the server command directly inside the proxy container:
# docker-compose.yaml
services:
proxy:
build:
context: .
dockerfile: Dockerfile.stdio
ports:
- "8080:8080"
environment:
- WAXELL_API_KEY=${WAXELL_API_KEY}
- WAXELL_API_URL=${WAXELL_API_URL}
With a specialized Dockerfile that includes Node.js for the MCP filesystem server:
# Dockerfile.stdio
FROM python:3.12-slim
# Install Node.js for npx-based MCP servers
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN pip install --no-cache-dir "waxell-observe[mcp-server]"
COPY proxy-stdio.yaml proxy.yaml
ENTRYPOINT ["wax", "observe", "mcp-proxy"]
CMD ["--config", "proxy.yaml", "--transport", "http", "--port", "8080"]
# proxy-stdio.yaml
target:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
name: "fs-proxy"
transport: http
port: 8080
api_key: "${WAXELL_API_KEY}"
api_url: "${WAXELL_API_URL}"
HTTP Transport Configuration
When using --transport http, the proxy exposes an HTTP endpoint that MCP clients can connect to using streamable HTTP transport.
# Start proxy with HTTP transport
wax observe mcp-proxy --config proxy.yaml --transport http --host 0.0.0.0 --port 8080
Clients connect to http://<host>:<port>/mcp:
from mcp.client.streamable_http import streamable_http_client
async with streamable_http_client("http://localhost:8080/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(name="read_file", arguments={"path": "/tmp/test.txt"})
When to Use HTTP Transport
| Scenario | Transport | Why |
|---|---|---|
| Agent and proxy in same process | stdio | Simplest, no networking |
| Agent and proxy in different containers | http | Required for network communication |
| Multiple agents sharing one proxy | http | Allows concurrent connections |
| Production deployment | http | Standard for networked services |
ProxyConfig Reference
Complete reference for all ProxyConfig fields:
| Field | Type | Default | Description |
|---|---|---|---|
target | dict | (required) | Target server spec: {url}, {command, args, env}, or {path} |
name | str | "waxell-proxy" | Proxy name for MCP metadata and span attribution |
transport | str | "stdio" | Inbound transport: "stdio" or "http" |
host | str | "0.0.0.0" | HTTP bind host |
port | int | 8080 | HTTP bind port |
api_key | str | None | None | Waxell API key for controlplane |
api_url | str | None | None | Waxell API URL |
default_pii_scan | str | "standard" | Default PII scan mode: "none", "standard", "strict" |
default_policy_action | str | "allow" | Default policy action: "allow", "block", "warn" |
fail_open | bool | true | Governance errors let tool calls proceed |
tools | dict[str, ToolPolicyConfig] | {} | Per-tool governance policies |
fingerprinting | bool | true | Capture tool fingerprints on first tools/list |
rug_pull_detection | bool | true | Alert on tool definition changes between sessions |
ToolPolicyConfig Reference
Per-tool policy overrides in the tools section:
| Field | Type | Default | Description |
|---|---|---|---|
pii_scan | str | "standard" | PII scan mode: "none", "standard", "strict" |
policy_action | str | None | None | Override policy action: "allow", "block", "warn" |
rate_limit | dict | None | None | Rate limit: {max_per_minute, max_per_hour} |
approval_required | bool | false | Require approval before execution |
PII Scan Modes
| Mode | Behavior |
|---|---|
"none" | No PII scanning |
"standard" | Warn on all PII types (SSN, credit card, email, phone, AWS keys, API keys, private keys) |
"strict" | Block on all PII types -- tool call is rejected if PII is found in inputs |
Output PII scanning always warns (never blocks) because the tool has already executed.
Production Considerations
Logging
The proxy logs to stderr via the waxell logger. Configure log level with the WAXELL_LOG_LEVEL environment variable:
WAXELL_LOG_LEVEL=DEBUG wax observe mcp-proxy --config proxy.yaml
When Rich is installed (included with waxell-observe), the CLI displays formatted startup information on stderr:
waxell-proxy starting
Target: proxy.yaml
Transport: stdio
PII scan: standard
Fail-open: true
Security: fingerprinting, rug-pull-detection, controlplane-policy
Fail-Open Behavior
By default, fail_open: true means governance errors (controlplane unreachable, PII scanner crash, policy check timeout) let tool calls proceed normally. This ensures the proxy never breaks your agent's workflow.
Set fail_open: false for strict governance enforcement where you'd rather fail than allow an ungoverned call:
fail_open: false # tool calls fail if governance can't run
With fail_open: false, a controlplane outage will block all tool calls through the proxy. Only use this in environments where governance is a hard requirement (compliance-critical systems).
Security Features
The proxy includes two security features enabled by default:
Tool Fingerprinting (fingerprinting: true): On the first tools/list call, the proxy captures a SHA256 hash of each tool's definition (name + description + input schema + output schema). This fingerprint is recorded on spans for audit purposes.
Rug Pull Detection (rug_pull_detection: true): On subsequent tools/list calls, the proxy compares tool definitions against the stored fingerprints. If a tool's description or schema has changed, it records an alert on the span with the diff. This detects when a third-party server modifies tool behavior between sessions.
Disable either feature if they add unwanted overhead:
wax observe mcp-proxy --config proxy.yaml --no-fingerprint --no-rug-pull-detection
Health Monitoring
Monitor the proxy by watching for:
- Span attributes:
waxell.mcp.governance_checkedshould betrueon all proxied calls - Error spans:
waxell.mcp.is_errorindicates tool-level errors - Rate limit spans:
waxell.mcp.rate_limitedindicates rate limit enforcement - PII detection:
waxell.mcp.pii_detectedindicates PII was found in inputs or outputs - Policy blocks:
waxell.mcp.governance_action="block"indicates a policy blocked the call
Programmatic Usage
You can also create and run the proxy programmatically:
from waxell_observe.mcp_proxy import ProxyServer, ProxyConfig, create_proxy_server
# From config file
server = create_proxy_server(config_path="proxy.yaml")
server.run()
# Quick target mode
server = create_proxy_server(
target="http://remote-server:9000/mcp",
api_key="wax_sk_...",
)
server.run(transport="http")
# Full control
config = ProxyConfig(
target={"url": "http://remote-server:9000/mcp"},
name="my-proxy",
transport="http",
port=9090,
api_key="wax_sk_...",
default_pii_scan="strict",
tools={
"send_email": {"pii_scan": "strict", "approval_required": True},
},
)
proxy = ProxyServer(config)
proxy.run()
Async usage
import asyncio
from waxell_observe.mcp_proxy import ProxyServer, ProxyConfig
config = ProxyConfig(
target={"url": "http://remote-server:9000/mcp"},
name="async-proxy",
transport="http",
port=8080,
)
proxy = ProxyServer(config)
async def main():
await proxy.run_async()
asyncio.run(main())
Next Steps
- Governing Third-Party Providers -- Detailed examples for Composio, Pipedream, and public MCP servers
- Architecture -- How the proxy fits with the auto-instrumentor and server middleware