Skip to main content

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

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.

Already have waxell-observe?

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:

  1. Rate limit check -- If a per-tool rate limit is configured and exceeded, the call is blocked with a ToolError.
  2. Policy check -- If connected to the Waxell controlplane, queries the policy engine. A block action stops the call before execution.
  3. PII scan (inputs) -- Scans tool arguments for PII (SSNs, credit cards, API keys, etc.). In strict mode, PII in inputs blocks the call.
  4. Tool executes -- Your actual tool function runs.
  5. PII scan (outputs) -- Scans the tool's response for PII. Output findings are logged as warnings (the tool already executed).
  6. Audit trail -- All governance decisions, PII findings, and a SHA256 hash of the arguments are recorded as span attributes.
Fail-open by default

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:

AttributeExample ValueDescription
Span namemcp_server/tool read_fileFormat: mcp_server/tool {tool_name}
waxell.mcp.tool_nameread_fileTool that was called
waxell.mcp.server_sidetrueDistinguishes from client-side spans
waxell.mcp.middleware_activetrueGovernance middleware was active
waxell.mcp.governance_checkedtrueGovernance pipeline completed
waxell.mcp.governance_actionallowPolicy decision for this call
waxell.mcp.governance_timestamp2026-03-06T14:00:00ZWhen governance ran
waxell.mcp.params_hasha1b2c3d4e5f67890SHA256 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.

PropertyClient-side (auto-instrumentor)Server-side (middleware)
Span kindCLIENTSERVER
Span nametools/call {server}:{tool}mcp_server/tool {tool_name}
Created byAgent's MCP clientServer's GovernanceMiddleware
Governance scopeAgent-level policiesServer-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:

  1. Auto-initializes OTel -- On the first tool call, calls waxell_observe.init() (idempotent) to set up OpenTelemetry tracing.
  2. Creates a root span -- Each tool call gets its own independent trace with SpanKind.SERVER. No propagation from the calling client.
  3. Extracts caller identity -- Records the MCP client ID, session ID, IP address, and user agent (when available via HTTP transport).
  4. Resolves tool config -- Checks for per-tool configuration in priority order: @governance decorator, constructor tool_configs, server defaults.
  5. Runs the governance pipeline -- Rate limit, policy check, PII scan, execute, PII scan, audit.
  6. Records everything -- All decisions and findings are recorded as span attributes for dashboards and alerting.
Works without a controlplane

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 @governance decorators, 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