Connecting to the Controlplane
GovernanceMiddleware works in two modes: standalone (local governance only) and connected (centralized policy management via the Waxell controlplane). This page explains how to connect your middleware to the controlplane and what happens when the controlplane is unreachable.
Prerequisites
- Python 3.10+
- A FastMCP server with GovernanceMiddleware installed (see Middleware Quickstart)
- A Waxell account with an API key
pip install waxell-observe[mcp-server]
Standalone vs Connected Mode
| Feature | Standalone | Connected |
|---|---|---|
| PII scanning | Yes | Yes |
| Rate limiting | Yes | Yes |
| OTel span recording | Yes | Yes |
Per-tool config (@governance, tool_configs) | Yes | Yes |
| Centralized policy checks | No | Yes |
| Remote policy management | No | Yes |
| Audit trail in controlplane | Spans only | Spans + policy decisions |
Standalone mode is the default when no api_key is provided. All local governance features (PII scanning, rate limiting, per-tool config) work without a controlplane connection. Policy checks are skipped silently.
Connected mode activates when you provide an api_key. The middleware sends policy check requests to the controlplane before every tool call, using the mcp:{tool_name} pattern.
Setting Up the Connection
Provide your API key and API URL to the GovernanceMiddleware constructor:
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware
mcp = FastMCP("MyServer")
mcp.add_middleware(GovernanceMiddleware(
api_key="wax_sk_...",
api_url="https://acme.waxell.dev",
))
You can also use environment variables by reading them at initialization:
import os
mcp.add_middleware(GovernanceMiddleware(
api_key=os.environ.get("WAXELL_API_KEY", ""),
api_url=os.environ.get("WAXELL_API_URL", ""),
))
Auto-Initialization
On the first tool call, the middleware automatically calls waxell_observe.init() with the provided credentials. This sets up OTel tracing and the controlplane connection. The init call is idempotent -- subsequent tool calls skip initialization.
You do not need to call waxell.init() separately in your server code.
How Policy Checks Work
When a tool call arrives, the middleware sends a policy check to the controlplane using the pattern mcp:{tool_name}:
Tool call: read_file(path="/etc/passwd")
-> Policy check: mcp:read_file
-> Controlplane returns: { action: "block", reason: "Sensitive file access" }
-> ToolError raised, tool does not execute
The controlplane evaluates all matching policy rules and returns an action:
| Action | Meaning | Tool executes? |
|---|---|---|
allow | Policy permits the call | Yes |
block | Policy forbids the call | No -- ToolError raised |
warn | Policy flags the call but permits execution | Yes (warning logged) |
throttle | Policy permits the call after an enforced delay | Yes (after delay, capped at 30s) |
Policy Action Override
Per-tool configuration can override the controlplane's action. If a ToolGovernanceConfig specifies policy_action, that value takes precedence:
from waxell_observe.mcp import GovernanceMiddleware, ToolGovernanceConfig
mcp.add_middleware(GovernanceMiddleware(
api_key="wax_sk_...",
tool_configs={
"delete_user": ToolGovernanceConfig(
policy_action="block", # Always block, regardless of controlplane
),
},
))
Fail-Open Semantics
When the controlplane is unreachable, the middleware allows tool calls to proceed by default. This ensures your MCP server remains functional even during controlplane outages.
The fail_open setting controls behavior when governance processing fails:
| Scenario | fail_open=True (default) | fail_open=False |
|---|---|---|
| Controlplane unreachable | Tool executes, policy_skipped span attribute set | Governance error propagates |
| Policy check timeout | Tool executes with warning | Error raised |
| PII scan error | Tool executes with warning | Error raised |
| Rate limit check | Always enforced (local) | Always enforced (local) |
Intentional blocks (ToolError) | Always propagated | Always propagated |
Key distinction: Intentional blocks (rate limit exceeded, policy action = "block", PII detected in strict mode) always raise ToolError regardless of the fail_open setting. Fail-open only applies to unexpected errors in the governance pipeline itself.
Configuring Fail-Open
mcp.add_middleware(GovernanceMiddleware(
api_key="wax_sk_...",
policy_config={
"fail_open": True, # Default: governance errors let calls proceed
},
))
To run in fail-closed mode (governance errors block tool calls):
mcp.add_middleware(GovernanceMiddleware(
api_key="wax_sk_...",
policy_config={
"fail_open": False, # Governance errors block tool calls
},
))
What Gets Recorded on the Span
When the controlplane is unreachable and fail-open activates:
| Span Attribute | Value |
|---|---|
waxell.mcp.policy_skipped | true |
waxell.mcp.governance_checked | false |
waxell.mcp.governance_action | Not set (no policy decision available) |
This lets you query for calls that bypassed governance:
{ span.waxell.mcp.policy_skipped = true }
Environment Variable Configuration
For production deployments, pass credentials via environment variables rather than hardcoding them:
export WAXELL_API_KEY="wax_sk_..."
export WAXELL_API_URL="https://acme.waxell.dev"
import os
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware
mcp = FastMCP("ProductionServer")
mcp.add_middleware(GovernanceMiddleware(
api_key=os.environ.get("WAXELL_API_KEY", ""),
api_url=os.environ.get("WAXELL_API_URL", ""),
policy_config={
"default_pii_scan": "strict",
"fail_open": True,
},
))
Full Example
Complete server connected to controlplane with policy scenarios
import os
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware, ToolGovernanceConfig, governance
mcp = FastMCP("GovernedDocServer")
# Connect to controlplane with per-tool overrides
mcp.add_middleware(GovernanceMiddleware(
api_key=os.environ.get("WAXELL_API_KEY", ""),
api_url=os.environ.get("WAXELL_API_URL", ""),
policy_config={
"default_pii_scan": "standard",
"default_policy_action": "allow",
"fail_open": True,
},
tool_configs={
# Always block delete operations, even if controlplane allows
"delete_document": ToolGovernanceConfig(
policy_action="block",
pii_scan="strict",
),
},
))
@mcp.tool
async def read_document(doc_id: str) -> str:
"""Read a document by ID.
Controlplane policy check runs with pattern: mcp:read_document
Uses server defaults (standard PII scan, allow policy action).
"""
return f"Document {doc_id} content..."
@governance(approval_required=True, pii_scan="strict")
@mcp.tool
async def update_document(doc_id: str, content: str) -> dict:
"""Update a document's content.
@governance decorator overrides: strict PII scan, approval required.
Controlplane policy check runs with pattern: mcp:update_document
"""
return {"status": "updated", "doc_id": doc_id}
@mcp.tool
async def delete_document(doc_id: str) -> dict:
"""Delete a document permanently.
Constructor tool_configs override: always blocked.
Even if the controlplane returns action="allow", the local
policy_action="block" takes precedence.
"""
return {"status": "deleted", "doc_id": doc_id}
@governance(rate_limit={"max_per_minute": 5})
@mcp.tool
async def search_documents(query: str) -> list:
"""Search documents by query.
Rate limited to 5 calls per minute via @governance decorator.
Controlplane policy check runs with pattern: mcp:search_documents
"""
return [{"doc_id": "1", "title": "Result", "snippet": "..."}]
if __name__ == "__main__":
mcp.run()
Run the server:
export WAXELL_API_KEY="wax_sk_..."
export WAXELL_API_URL="https://acme.waxell.dev"
python server.py
Next Steps
- Middleware API Reference -- Complete reference for all middleware classes and parameters
- Architecture -- How middleware fits into the broader MCP governance architecture