Skip to main content

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

OptionShortDefaultDescription
--config-c--Path to a YAML or JSON config file
--target-t--Target server URL or path (quick-start mode)
--transportstdioInbound transport: stdio or http
--port8080Port for HTTP transport
--host0.0.0.0Host for HTTP transport
--api-key$WAXELL_API_KEYWaxell API key for controlplane policy checks
--api-url$WAXELL_API_URLWaxell API URL
--namewaxell-proxyProxy server name (used in spans and metadata)
--no-fingerprintDisable tool fingerprinting
--no-rug-pull-detectionDisable 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 or JSON?

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
Unresolved variables

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
Transport choice in Docker

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
Docker networking

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

ScenarioTransportWhy
Agent and proxy in same processstdioSimplest, no networking
Agent and proxy in different containershttpRequired for network communication
Multiple agents sharing one proxyhttpAllows concurrent connections
Production deploymenthttpStandard for networked services

ProxyConfig Reference

Complete reference for all ProxyConfig fields:

FieldTypeDefaultDescription
targetdict(required)Target server spec: {url}, {command, args, env}, or {path}
namestr"waxell-proxy"Proxy name for MCP metadata and span attribution
transportstr"stdio"Inbound transport: "stdio" or "http"
hoststr"0.0.0.0"HTTP bind host
portint8080HTTP bind port
api_keystr | NoneNoneWaxell API key for controlplane
api_urlstr | NoneNoneWaxell API URL
default_pii_scanstr"standard"Default PII scan mode: "none", "standard", "strict"
default_policy_actionstr"allow"Default policy action: "allow", "block", "warn"
fail_openbooltrueGovernance errors let tool calls proceed
toolsdict[str, ToolPolicyConfig]{}Per-tool governance policies
fingerprintingbooltrueCapture tool fingerprints on first tools/list
rug_pull_detectionbooltrueAlert on tool definition changes between sessions

ToolPolicyConfig Reference

Per-tool policy overrides in the tools section:

FieldTypeDefaultDescription
pii_scanstr"standard"PII scan mode: "none", "standard", "strict"
policy_actionstr | NoneNoneOverride policy action: "allow", "block", "warn"
rate_limitdict | NoneNoneRate limit: {max_per_minute, max_per_hour}
approval_requiredboolfalseRequire approval before execution

PII Scan Modes

ModeBehavior
"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
Strict mode in production

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_checked should be true on all proxied calls
  • Error spans: waxell.mcp.is_error indicates tool-level errors
  • Rate limit spans: waxell.mcp.rate_limited indicates rate limit enforcement
  • PII detection: waxell.mcp.pii_detected indicates 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