Warden Client Proxy

The Warden Client Proxy sits between MCP clients (Claude Desktop, Cursor, ChatGPT) and MCP servers (local or remote), providing security filtering, auditing, and sensitive pattern blocking.

⚠️ What the proxy does and does not isolate: the proxy filters MCP JSON-RPC messages and restricts a stdio subprocess's environment to env.allow. It does not sandbox a stdio upstream's filesystem or network access — wrap warden proxy itself in warden run (or a container) when the upstream must be isolated. See Security Considerations below.

Overview

Traditional MCP setup:

Claude Desktop ──────► Remote MCP Server (mcp.github.com)
     │                        │
   All secrets            All requests
   exposed               unfiltered

With Warden Client Proxy:

Claude Desktop ──► Warden Proxy ──► Remote MCP Server
     │                  │                   │  
 Only allowed       Filters &           Approved
   secrets          audits all         requests only
                    requests

Key Features

  • Secret Protection: Only explicitly allowed environment variables are accessible
  • Tool Filtering: Restrict which MCP tools can be called
  • Pattern Blocking: Block requests/responses containing sensitive patterns (API keys, tokens, etc.)
  • Audit Logging: Complete logging of all MCP communications
  • Transport Support: stdio subprocesses and remote HTTP/SSE upstreams (Streamable HTTP). Plain http:// is loopback-only; remote upstreams must use https:// (TLS verified)

Quick Start

1. Create MCP Proxy Policy

Any MCP server works: a local stdio command, or a remote Streamable HTTP/SSE endpoint (directly, or bridged via mcp-remote if you prefer a stdio hop):

# mcp-proxy-policy.yaml
mcp:
  # Option Aremote HTTPS upstream (Streamable HTTP / SSE):
  upstream: "https://mcp.github.com/mcp"

  # Option Blocal stdio command (or a stdio bridge to a remote server):
  # upstream: "stdio:npx -y mcp-remote https://mcp.github.com/mcp"

  allow_tools:
    - "list_repositories"
    - "get_file"
    - "search_code"

  deny_patterns:
    - "ghp_[A-Za-z0-9]{36}"        # GitHub tokens
    - "sk-[A-Za-z0-9]{48}"         # OpenAI keys
    - "AKIA[A-Z0-9]{16}"           # AWS keys

env:
  allow: ["PATH", "HOME", "GITHUB_TOKEN"]  # stdio subprocess gets only these (deny-by-default)

2. Start the Proxy

warden proxy --policy mcp-proxy-policy.yaml --listen localhost:8765

3. Configure Your Client

The proxy speaks newline-delimited JSON-RPC over TCP (stdio-style framing). Point your MCP client at the proxy through a stdio-to-TCP bridge, or use clients that can connect directly. Example with socat/ncat for a manual test:

# Send a JSON-RPC message to the proxy from the command line
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \
  timeout 2 bash -c 'cat > /dev/tcp/127.0.0.1/8765 & cat < /dev/tcp/127.0.0.1/8765'

The proxy's client-facing listener is the same for every upstream type; what changed is the upstream side: https:// upstreams are now contacted over real HTTP/SSE instead of being rejected.

Policy Configuration

MCP Section

mcp:
  # Required: Upstream MCP server. Three forms are accepted:
  #   "stdio:<command>"         local subprocess bridge
  #   "https://host/path"       remote Streamable HTTP upstream (TLS verified)
  #   "http://127.0.0.1:PORT"   plain HTTP allowed for loopback dev servers only
  upstream: "https://mcp.github.com/mcp"
  
  # Optional: Allowed tool names (deny-by-default if specified)
  allow_tools:
    - "list_repositories"
    - "get_file"
    - "create_issue"
  
  # Optional: Patterns to block in payloads
  deny_patterns:
    - "ghp_[A-Za-z0-9]{36}"        # GitHub personal access tokens
    - "sk-[A-zA-Z0-9]{48}"         # OpenAI API keys
    - "xoxb-[0-9]{12}-[0-9]{12}-[a-zA-Z0-9]{24}"  # Slack tokens
  
  # Optional: Max payload size (KB) to prevent DoS
  max_payload_kb: 1024
  
  # Optional: Log all requests (careful - may log sensitive data)
  audit_requests: false

Standard Sandbox Sections

The proxy also supports standard Warden policy sections for stdio servers:

# filesystem/network/limits do NOT sandbox the stdio subprocess today —
# see Security Notes below.
filesystem:
  read: ["."]
  write: ["./tmp"]

network:
  allow: ["api.github.com"]

env:
  allow: ["GITHUB_TOKEN", "PATH"]  # the ONLY section enforced for the subprocess

⚠️ Honest security model (current): Warden filters and audits every JSON-RPC message in both directions, and the stdio subprocess receives only the variables listed in env.allow (deny-by-default). The subprocess is not sandboxedits filesystem and network access are unrestricted. The filesystem/network sections above are not enforced by the proxy.

Security Benefits

Secret Protection

Without Warden Proxy:

// All environment variables accessible to MCP server
process.env.GITHUB_TOKEN    // ✓ Intended
process.env.AWS_SECRET_KEY  // ❌ Exposed!
process.env.DATABASE_URL    // ❌ Exposed!

With Warden Proxy:

// Only explicitly allowed variables accessible
process.env.GITHUB_TOKEN    // ✓ Allowed in policy
process.env.AWS_SECRET_KEY  // ❌ Blocked by proxy
process.env.DATABASE_URL    // ❌ Blocked by proxy

Pattern-Based Blocking

The proxy scans all MCP messages and blocks those containing sensitive patterns:

// This request would be blocked:
{
  "method": "tools/create_issue", 
  "params": {
    "title": "Bug report",
    "body": "API key: ghp_1234567890abcdef1234567890abcdef12"
  }
}
// Blocked: Contains GitHub token pattern

Tool Restriction

mcp:
  allow_tools: ["list_repos", "get_file"]
  # create_issue, delete_repo, etc. are blocked

Transport Types

HTTP/HTTPS Remote Servers (implemented)

mcp:
  upstream: "https://mcp.github.com/api"

The proxy POSTs each filtered JSON-RPC message upstream (Streamable HTTP convention: Accept: application/json, text/event-stream) and relays the response through the same inbound filter. TLS certificates are verified (TLS 1.2+ minimum); a plain http:// upstream is accepted only for loopback hosts (127.0.0.1, localhost), so dev servers work while remote traffic is never sent unencrypted.

Server-Sent Events (SSE) (implemented)

mcp:
  upstream: "https://mcp.github.com/stream"

Client requests ride POST bodies; the response's SSE data: lines are filtered and relayed. The upstream's GET event stream (server→client notifications) is held open for the session and relayed through the same inbound filter, and is torn down promptly when the client disconnects.

Stdio Local Servers

mcp:
  upstream: "stdio:npx @modelcontextprotocol/server-github"

The proxy spawns the local MCP server as a subprocess and bridges stdio communication.

Use Cases

AI Development with Remote APIs

# Protect against AI tools leaking secrets
mcp:
  upstream: "https://api.anthropic.com/mcp" 
  deny_patterns: ["sk-ant-[a-zA-Z0-9]+"]
  allow_tools: ["analyze_code"]  # Not "send_email"

Multi-User Development Teams

# Team policy for shared MCP servers
mcp:
  upstream: "https://company-mcp.internal/api"
  allow_tools: ["read_docs", "search_code"]  # No write operations
  deny_patterns: ["[a-zA-Z0-9]{32}"]  # Block long tokens

Security Research

# Analyze unknown MCP servers safely
mcp:
  upstream: "https://untrusted-mcp.example.com"
  allow_tools: []  # Block all tools
  audit_requests: true  # Log everything
  max_payload_kb: 100   # Limit payload size

Command Line Options

warden proxy [OPTIONS]

Options:
  --policy <file>     MCP proxy policy file (required)
  --listen <addr>     Listen address (default: localhost:8765)  
  --upstream <url>    Override upstream from policy
  --help              Show help

Examples:
  warden proxy --policy mcp-policy.yaml
  warden proxy --policy mcp-policy.yaml --listen :9000
  warden proxy --policy mcp-policy.yaml --upstream "stdio:cat"

Audit Logging

All MCP activity is logged to the Warden audit log:

# View MCP proxy activity
warden logs --tail 10

# Filter for blocked requests
warden logs | grep '"type":"mcp_proxy"' | grep '"allowed":false'

Example audit events:

{"timestamp":"2026-09-10T00:00:00Z","type":"mcp_proxy","action":"stdio_proxy","resource":"npx @modelcontextprotocol/server-github","allowed":true,"reason":"stdio MCP upstream started"}
{"timestamp":"2026-09-10T00:00:01Z","type":"mcp_proxy","action":"mcp_block","resource":"tools/call","allowed":false,"reason":"tool \"delete_repo\" not in allow list"}

Architecture

Client Request
      │
      ▼
┌─────────────┐
│   Policy    │ ◄── mcp: section validates
│ Validation  │     upstream, tools, patterns
└─────────────┘
      │
      ▼
┌─────────────┐
│   Message   │ ◄── Parse JSON-RPC
│   Parsing   │     Extract method, params
└─────────────┘
      │
      ▼
┌─────────────┐
│  Filtering  │ ◄── Check allow_tools
│   Engine    │     Scan deny_patterns
└─────────────┘
      │
      ▼
┌─────────────┐
│   Upstream  │ ◄── Forward to MCP server
│    Proxy    │     stdio transport only (subprocess stdin/stdout)
└─────────────┘
      │
      ▼
┌─────────────┐
│   Audit     │ ◄── Log all activity
│   Logger    │     Both allowed & blocked
└─────────────┘

Implementation Status

Working today (verified by unit and end-to-end tests):

  • warden proxy runs a real stdio JSON-RPC bridge: spawns the upstream subprocess, relays newline-delimited JSON-RPC in both directions
  • Remote HTTP/SSE upstreams (Streamable HTTP): POST-per-message with SSE-framed responses, plus the server→client GET event streamall filtered, all audited, TLS verified, plain http:// loopback-only
  • Mcp-Session-Id handling: the id issued on the initialize response is captured and replayed on every subsequent request, so session-based servers (Microsoft Learn, GitHub, …) keep one upstream session per client connection
  • Tool allowlist, deny patterns, and payload-size enforcement in both directions
  • Env filtering: the stdio subprocess receives only env.allow variables (deny-by-default)
  • JSON-RPC errors for blocked (-32001) and unparseable (-32700) messages; -32603 when the upstream itself fails (fail-closed, never a silent hang)
  • Audit logging for every allow/block decision (mcp_message, mcp_block, stdio_proxy events)

Not implemented / limitations:

  • Sandbox isolation of the stdio subprocess (filesystem/network are unrestricted)
  • Direct MCP-client protocols (the TCP listener speaks JSON-RPC lines, not HTTP)
  • OAuth token minting: the proxy is a pass-through; it forwards whatever auth your client establishes (for stdio bridges, env.allow governs token reachability)

Known safe by design: every blocked message is answered with a JSON-RPC error and audited; nothing blocked is ever forwardedincluding on the HTTP path, where a filtered payload never leaves loopback.

Live Interop Results

The HTTP/SSE transport was verified against real, public MCP servers on 2026-09-11 (warden-sandbox-cli 0.1.13, local build from this tree). The client below spoke plain newline-delimited JSON-RPC to warden proxy; the proxy handled the Streamable HTTP framing, TLS, and session management.

Upstreams tested:

UpstreamTransport notesResult
https://mcp.deepwiki.com/mcp (DeepWiki)No session id; POST responses are text/event-stream frames5/5 pass
https://learn.microsoft.com/api/mcp (Microsoft Learn)Issues Mcp-Session-Id; session replay verified across tools/list + tools/call4/4 pass
https://api.githubcopilot.com/mcp/ (GitHub)Requires OAuth Bearer (401 without token)out of proxy scope, fail-closed as designednot proxied
Dead host (*.invalid)DNS failure surfaces as JSON-RPC -32603 to the client, no hangpass (fail-closed)

Test matrix against DeepWiki (policy: allowlist = read_wiki_structure, ask_question; deny pattern = ghp_[A-Za-z0-9]{36}):

#ScenarioExpectedResult
1initialize handshakeserverInfo.name = DeepWiki relayedPASS
2tools/list (discovery passes the filter)3 tools listedPASS
3tools/call read_wiki_structure (allowlisted)forwarded, result relayedPASS
4tools/call read_wiki_contents (NOT allowlisted)-32001 blocked by Warden policy: tool ... not in allow list; upstream never contactedPASS
5allowlisted call whose payload contains a ghp_… token-32001 … blocked pattern: ghp_[A-Za-z0-9]{36}; payload never leaves loopbackPASS

Corresponding audit trail (warden logs) for the same sessionevery decision recorded, allowed and blocked:

{"type":"mcp_proxy","action":"mcp_message","resource":"tools/call","allowed":true,"reason":"direction=outbound id=3"}
{"type":"mcp_proxy","action":"mcp_block","resource":"tools/call","allowed":false,"reason":"tool \"read_wiki_contents\" not in allow list"}
{"type":"mcp_proxy","action":"mcp_block","resource":"tools/call","allowed":false,"reason":"message contains blocked pattern: ghp_[A-Za-z0-9]{36}"}

What the live run changed in the implementation (interop fixes, each now pinned by the unit/e2e test suite):

  1. SSE-framed POST responses. Production servers answer POSTs with Content-Type: text/event-stream bodies, not raw JSON lines. The proxy now dispatches on the response media type and runs SSE-framed bodies through the same data:-line filter (caught live: both earlier attempts would have been dropped as "unparseable").
  2. Mcp-Session-Id replay. Session-minting servers (Microsoft Learn) reject post-initialize calls that omit the id. The initialize response's id is captured once per proxy process and replayed on every subsequent POST and GET.
  3. GET-without-session is spec-legal. Opening the server→client event stream on a server that issues no session id returns HTTP 406treated as "no GET stream", not a failure; POST responses still deliver results.

Reproduction sketch (any MCP client that speaks newline-delimited JSON-RPC over TCP, e.g. a stdio bridge):

cat > mcp-proxy-policy.yaml <<'YAML'
mcp:
  upstream: "https://mcp.deepwiki.com/mcp"
  allow_tools: ["read_wiki_structure", "ask_question"]
  deny_patterns: ["ghp_[A-Za-z0-9]{36}"]
  max_payload_kb: 256
YAML
warden proxy --policy mcp-proxy-policy.yaml --listen 127.0.0.1:8765
# then send JSON-RPC lines to 127.0.0.1:8765 (initialize first)

Troubleshooting

Common Issues

  1. "Policy must contain 'mcp' section"

    • Add mcp: section to your policy file
    • Ensure upstream is specified
  2. "Connection refused"

    • Check that the upstream MCP server is accessible at its address (the proxy itself does not consult network.allow for upstream connections; https:// upstreams must be reachable and TLS-valid)
  3. "Tool not in allow list"

    • Add the tool name to mcp.allow_tools
    • Or remove allow_tools to allow all tools
  4. "Blocked pattern detected"

    • Review mcp.deny_patterns for overly broad rules
    • Check audit log for the specific blocked pattern

Debug Mode

# Enable verbose logging
WARDEN_DEBUG=1 warden proxy --policy mcp-policy.yaml

# Monitor audit log in real-time
warden logs --follow &
warden proxy --policy mcp-policy.yaml

Security Considerations

  1. Pattern Selection: Choose deny patterns carefully to avoid false positives
  2. Audit Logging: Consider storage/retention for audit logs
  3. Env allowlist: Only pass secrets the upstream actually needs (env.allow is the only section that isolates the subprocess)
  4. Tool Permissions: Use minimal allow_tools for principle of least privilege
  5. Payload Limits: Set max_payload_kb to prevent resource exhaustion
  6. No process isolation yet: the stdio subprocess runs unsandboxed on your hostwrap warden proxy itself in warden run (or a container) if the upstream must be filesystem/network isolated

Future Enhancements

  • Browser extension for easy client configuration
  • Machine learning-based sensitive content detection
  • Integration with security information and event management (SIEM) systems
  • Support for custom MCP authentication methods
  • GraphQL-style query allowlisting for complex MCP operations

Related Documentation