Configure Governance Per Tool
Not every tool needs the same governance. A read_file tool might need only basic PII scanning, while send_email needs strict scanning and rate limiting, and delete_user requires approval before execution. GovernanceMiddleware lets you configure governance per tool using three methods that layer on top of each other.
Who is this for? MCP server builders who have added GovernanceMiddleware (from the Middleware Quickstart) and want to fine-tune governance for individual tools.
Prerequisites
- Python 3.10+
- A Waxell API key -- get one from your Waxell dashboard
- waxell-observe with server extra installed:
pip install waxell-observe[mcp-server]
Three Ways to Configure
GovernanceMiddleware resolves per-tool configuration in priority order:
| Priority | Method | Best For |
|---|---|---|
| 1 (highest) | @governance decorator | Tool authors configuring their own tools inline |
| 2 | tool_configs constructor arg | Operators configuring tools they didn't write |
| 3 | Server defaults via policy_config | Baseline governance for all tools |
The first match wins. If a tool has both a @governance decorator and a tool_configs entry, the decorator config is used.
Method 1: @governance Decorator
Apply the @governance decorator directly on your tool function. This is the most natural approach when you're the tool author and want governance config to live alongside the tool code.
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware, governance
mcp = FastMCP("acme-server")
mcp.add_middleware(GovernanceMiddleware(api_key="wax_sk_..."))
@governance(pii_scan="strict", rate_limit={"max_per_minute": 10})
@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}"
The decorator accepts all ToolGovernanceConfig fields as keyword arguments:
@governance(
approval_required=True,
pii_scan="strict",
policy_action="warn",
rate_limit={"max_per_minute": 5, "max_per_hour": 50},
)
The @governance decorator works above or below @mcp.tool -- both orderings are supported. By convention, place @governance above @mcp.tool.
Method 2: tool_configs Constructor Argument
Pass a dictionary of ToolGovernanceConfig objects to the GovernanceMiddleware constructor. This is ideal when you're an operator configuring tools you didn't write, or when you want all configuration in one place.
from waxell_observe.mcp import GovernanceMiddleware, ToolGovernanceConfig
mcp.add_middleware(
GovernanceMiddleware(
api_key="wax_sk_...",
tool_configs={
"send_email": ToolGovernanceConfig(
pii_scan="strict",
approval_required=True,
rate_limit={"max_per_minute": 10},
),
"delete_user": ToolGovernanceConfig(
approval_required=True,
pii_scan="strict",
policy_action="block",
rate_limit={"max_per_minute": 5},
),
"query_database": ToolGovernanceConfig(
pii_scan="standard",
rate_limit={"max_per_hour": 100},
),
},
)
)
The keys in tool_configs are tool names -- they must match the function name registered with FastMCP.
Method 3: Server Defaults via policy_config
Set baseline governance for all tools using the policy_config dictionary. Any tool without a @governance decorator or tool_configs entry inherits these defaults.
mcp.add_middleware(
GovernanceMiddleware(
api_key="wax_sk_...",
policy_config={
"default_pii_scan": "standard",
"default_policy_action": "allow",
"fail_open": True,
},
)
)
| Field | Type | Default | Description |
|---|---|---|---|
default_pii_scan | str | "standard" | Default PII scanning mode for unconfigured tools |
default_policy_action | str | "allow" | Default policy action for unconfigured tools |
fail_open | bool | True | If True, governance errors let tool calls proceed. If False, governance errors block tool calls. |
PII Scan Modes
Each tool can use a different PII scanning mode:
| Mode | Behavior | When to Use |
|---|---|---|
"none" | No PII scanning | Internal tools with no user data |
"standard" | Scan and warn on all PII types | Most tools -- visibility without blocking |
"strict" | Scan and block on all PII types | Tools that handle sensitive data (email, payments, exports) |
PII types detected by the default RegexPIIScanner:
| Type | Examples |
|---|---|
| SSN | 123-45-6789 |
| Credit card | 4111-1111-1111-1111 |
user@example.com | |
| Phone | (555) 123-4567 |
| AWS access key | AKIAIOSFODNN7EXAMPLE |
| AWS secret key | wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY |
| API key | sk-abc123..., wax_sk_... |
| Private key | -----BEGIN RSA PRIVATE KEY----- |
Input PII scanning runs before the tool executes. In strict mode, a blocked PII type stops the call with a ToolError.
Output PII scanning runs after the tool executes. Output findings are always warnings -- the tool has already run, so blocking would discard valid results.
You can provide your own PII scanner by passing a pii_scanner argument to GovernanceMiddleware. The scanner must implement the PIIScanner protocol (a scan(text: str) -> dict method). See the Middleware Reference for details.
Rate Limiting
Configure per-tool rate limits to prevent abuse. Rate limits use a sliding window algorithm:
@governance(
rate_limit={
"max_per_minute": 10, # Max 10 calls per 60-second window
"max_per_hour": 100, # Max 100 calls per 3600-second window
},
)
@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}"
When a rate limit is exceeded, the middleware raises a ToolError with the message "Rate limit exceeded for tool 'send_email'" and records waxell.mcp.rate_limited: true on the span.
| Field | Type | Description |
|---|---|---|
max_per_minute | int | Maximum calls within a 60-second sliding window |
max_per_hour | int | Maximum calls within a 3600-second sliding window |
Both fields are optional. You can set one or both. The stricter limit applies when both are configured.
Rate limits are tracked in memory per server process. If you run multiple server instances behind a load balancer, each instance maintains its own counters. For global rate limiting across instances, use controlplane policies instead.
Approval Requirements
Mark tools that need human approval before execution:
@governance(approval_required=True)
@mcp.tool
async def delete_user(user_id: str) -> str:
"""Permanently delete a user account."""
return f"User {user_id} deleted"
When approval_required=True, the middleware records waxell.mcp.approval_required: true on the span. The actual approval workflow is managed by the Waxell controlplane -- see Connecting to Controlplane for setup.
ToolGovernanceConfig Reference
All fields available on ToolGovernanceConfig (used by both @governance decorator and tool_configs):
| Field | Type | Default | Description |
|---|---|---|---|
approval_required | bool | False | Whether the tool call requires approval before execution |
pii_scan | str | "standard" | PII scanning mode: "none", "standard", or "strict" |
policy_action | str | None | None | Override policy action: "allow", "block", or "warn". None uses the server default. |
rate_limit | dict | None | None | Rate limit config: {"max_per_minute": N, "max_per_hour": N}. None means no rate limit. |
Combining All Three Methods
In practice, you'll often use all three configuration methods together. Here's a realistic example:
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware, ToolGovernanceConfig, governance
mcp = FastMCP("acme-server")
# Server defaults: scan everything, allow by default
mcp.add_middleware(
GovernanceMiddleware(
api_key="wax_sk_...",
policy_config={
"default_pii_scan": "standard",
"default_policy_action": "allow",
"fail_open": True,
},
# Operator overrides for tools they didn't write
tool_configs={
"query_database": ToolGovernanceConfig(
rate_limit={"max_per_hour": 500},
),
},
)
)
# Tool author sets strict governance inline
@governance(pii_scan="strict", approval_required=True, rate_limit={"max_per_minute": 5})
@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 tool inherits from tool_configs (rate limit from operator)
@mcp.tool
async def query_database(sql: str) -> str:
"""Execute a read-only SQL query."""
return f"Results for: {sql}"
# This tool inherits server defaults (standard PII scan, allow policy)
@mcp.tool
async def read_file(path: str) -> str:
"""Read a file from the filesystem."""
return f"Contents of {path}"
Resolution for each tool:
| Tool | Config Source | PII Scan | Rate Limit | Approval |
|---|---|---|---|---|
send_email | @governance decorator | strict | 5/min | Yes |
query_database | tool_configs constructor | standard (default) | 500/hr | No |
read_file | Server defaults | standard | None | No |
Full runnable example with mixed per-tool configuration
from fastmcp import FastMCP
from waxell_observe.mcp import GovernanceMiddleware, ToolGovernanceConfig, governance
# Create server
mcp = FastMCP("acme-server")
# Configure middleware with all three methods
mcp.add_middleware(
GovernanceMiddleware(
api_key="wax_sk_...",
api_url="https://acme.waxell.dev",
# Method 3: Server defaults
policy_config={
"default_pii_scan": "standard",
"default_policy_action": "allow",
"fail_open": True,
},
# Method 2: Operator overrides
tool_configs={
"query_database": ToolGovernanceConfig(
pii_scan="standard",
rate_limit={"max_per_hour": 500},
),
"delete_user": ToolGovernanceConfig(
approval_required=True,
pii_scan="strict",
policy_action="block",
rate_limit={"max_per_minute": 3},
),
},
)
)
# Method 1: Tool author sets governance inline
@governance(
pii_scan="strict",
approval_required=True,
rate_limit={"max_per_minute": 5, "max_per_hour": 50},
)
@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}"
# Configured via tool_configs (Method 2)
@mcp.tool
async def query_database(sql: str) -> str:
"""Execute a read-only SQL query against the analytics database."""
return f"Results for: {sql}"
# Configured via tool_configs (Method 2)
@mcp.tool
async def delete_user(user_id: str) -> str:
"""Permanently delete a user account and all associated data."""
return f"User {user_id} deleted"
# Inherits server defaults (Method 3)
@mcp.tool
async def read_file(path: str) -> str:
"""Read a file from the filesystem."""
return f"Contents of {path}"
# Inherits server defaults (Method 3)
@mcp.tool
async def list_users() -> str:
"""List all users in the system."""
return "user1, user2, user3"
if __name__ == "__main__":
mcp.run()
Next Steps
- Connecting to Controlplane -- Set up centralized policy management so you can change governance rules without redeploying your server
- Middleware Reference -- Complete API reference for GovernanceMiddleware, ToolGovernanceConfig, MiddlewareConfig, and all span attributes