Add Governance to Your FastMCP Server
In this quickstart, you'll add governance to a FastMCP server so that every incoming tool call is checked for policy compliance, scanned for PII, rate-limited, and recorded as an OpenTelemetry span -- all before the tool executes. By the end, you'll have a governed MCP server running locally.
Who is this for? MCP server builders who control the server code and want to add governance directly inside the server, rather than relying on client-side instrumentation.
By the end, you'll have:
- A FastMCP server with 3 tools (
read_file,query_database,send_email) - GovernanceMiddleware intercepting every tool call
- PII scanning on tool inputs and outputs
- Governance spans appearing in your Waxell dashboard
Prerequisites
- Python 3.10+
- A Waxell API key -- get one from your Waxell dashboard
Step 1: Install
Install waxell-observe with the mcp-server extra, which brings in FastMCP and PyYAML:
pip install waxell-observe[mcp-server]
This installs waxell-observe along with fastmcp>=3.0,<4 and pyyaml>=6.0.
If you already have waxell-observe installed, add the server extra:
pip install "waxell-observe[mcp-server]"
The mcp-server extra is separate from the mcp (client) extra. You can install both with pip install "waxell-observe[mcp,mcp-server]".
Step 2: Create a FastMCP Server with Tools
Start with a standard FastMCP server that has three tools representing common server-side operations -- reading files, querying a database, and sending email:
from fastmcp import FastMCP
mcp = FastMCP("acme-server")
@mcp.tool
async def read_file(path: str) -> str:
"""Read a file from the filesystem."""
return f"Contents of {path}"
@mcp.tool
async def query_database(sql: str) -> str:
"""Execute a read-only SQL query."""
return f"Results for: {sql}"
@mcp.tool
async def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to a recipient."""
return f"Email sent to {to}: {subject}"
This is a standard FastMCP server. No governance yet -- any client can call any tool with any arguments.
Step 3: Add GovernanceMiddleware
Add governance with three lines of code:
from waxell_observe.mcp import GovernanceMiddleware
mcp.add_middleware(GovernanceMiddleware(api_key="wax_sk_..."))
That's it. Every incoming tool call now passes through the governance pipeline before reaching your tool function.
Here's what the middleware does on each tool call:
- Rate limit check -- If a per-tool rate limit is configured and exceeded, the call is blocked with a
ToolError. - Policy check -- If connected to the Waxell controlplane, queries the policy engine. A
blockaction stops the call before execution. - PII scan (inputs) -- Scans tool arguments for PII (SSNs, credit cards, API keys, etc.). In
strictmode, PII in inputs blocks the call. - Tool executes -- Your actual tool function runs.
- PII scan (outputs) -- Scans the tool's response for PII. Output findings are logged as warnings (the tool already executed).
- Audit trail -- All governance decisions, PII findings, and a SHA256 hash of the arguments are recorded as span attributes.
If the controlplane is unreachable or the PII scanner throws an error, the tool call proceeds normally. Governance errors never break your server. This fail-open behavior is configurable -- see the Per-Tool Configuration guide.
Step 4: Run and Verify
Save the complete server to a file and run it:
python my_server.py
The middleware auto-initializes OpenTelemetry tracing on the first tool call. Open your Waxell dashboard and navigate to the Traces view. You'll see server-side spans for each tool call with these attributes:
| Attribute | Example Value | Description |
|---|---|---|
| Span name | mcp_server/tool read_file | Format: mcp_server/tool {tool_name} |
waxell.mcp.tool_name | read_file | Tool that was called |
waxell.mcp.server_side | true | Distinguishes from client-side spans |
waxell.mcp.middleware_active | true | Governance middleware was active |
waxell.mcp.governance_checked | true | Governance pipeline completed |
waxell.mcp.governance_action | allow | Policy decision for this call |
waxell.mcp.governance_timestamp | 2026-03-06T14:00:00Z | When governance ran |
waxell.mcp.params_hash | a1b2c3d4e5f67890 | SHA256 fingerprint of the arguments |
Server-side vs. client-side spans
If your agents use the Waxell auto-instrumentor (from the Quickstart), you'll see both client-side and server-side spans for the same tool call. They're independent traces -- the middleware creates its own root span with SpanKind.SERVER, giving you visibility from both perspectives.
| Property | Client-side (auto-instrumentor) | Server-side (middleware) |
|---|---|---|
| Span kind | CLIENT | SERVER |
| Span name | tools/call {server}:{tool} | mcp_server/tool {tool_name} |
| Created by | Agent's MCP client | Server's GovernanceMiddleware |
| Governance scope | Agent-level policies | Server-level policies |
Full runnable example
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware
# Create server
mcp = FastMCP("acme-server")
# Add governance middleware
mcp.add_middleware(
GovernanceMiddleware(
api_key="wax_sk_...", # Your Waxell API key
api_url="https://acme.waxell.dev", # Your Waxell API URL
)
)
@mcp.tool
async def read_file(path: str) -> str:
"""Read a file from the filesystem."""
# In production, validate path and read the actual file
return f"Contents of {path}"
@mcp.tool
async def query_database(sql: str) -> str:
"""Execute a read-only SQL query against the analytics database."""
# In production, connect to your database and execute the query
return f"Results for: {sql}"
@mcp.tool
async def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to a recipient."""
# In production, use your email service
return f"Email sent to {to}: {subject}"
if __name__ == "__main__":
mcp.run()
Run it:
python my_server.py
Or use environment variables instead of hardcoding your API key:
export WAXELL_API_KEY="wax_sk_..."
export WAXELL_API_URL="https://acme.waxell.dev"
python my_server.py
How It Works
GovernanceMiddleware is a standard FastMCP Middleware subclass. It hooks into on_call_tool, which runs before every tool execution. The middleware:
- Auto-initializes OTel -- On the first tool call, calls
waxell_observe.init()(idempotent) to set up OpenTelemetry tracing. - Creates a root span -- Each tool call gets its own independent trace with
SpanKind.SERVER. No propagation from the calling client. - Extracts caller identity -- Records the MCP client ID, session ID, IP address, and user agent (when available via HTTP transport).
- Resolves tool config -- Checks for per-tool configuration in priority order:
@governancedecorator, constructortool_configs, server defaults. - Runs the governance pipeline -- Rate limit, policy check, PII scan, execute, PII scan, audit.
- Records everything -- All decisions and findings are recorded as span attributes for dashboards and alerting.
You don't need to connect to the Waxell controlplane to get value from the middleware. Without an API key, you still get:
- OpenTelemetry spans for every tool call
- PII scanning on inputs and outputs
- Rate limiting
- Audit trail with parameter hashing
The controlplane adds centralized policy management -- you can enable it later.
Next Steps
- Per-Tool Configuration -- Configure governance differently for each tool using
@governancedecorators, constructor configs, or server defaults - Connecting to Controlplane -- Set up centralized policy management with the Waxell controlplane
- Middleware Reference -- Complete API reference for GovernanceMiddleware, ToolGovernanceConfig, and all configuration options