Skip to main content

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:

PriorityMethodBest For
1 (highest)@governance decoratorTool authors configuring their own tools inline
2tool_configs constructor argOperators configuring tools they didn't write
3Server defaults via policy_configBaseline 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},
)
Decorator order

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,
},
)
)
FieldTypeDefaultDescription
default_pii_scanstr"standard"Default PII scanning mode for unconfigured tools
default_policy_actionstr"allow"Default policy action for unconfigured tools
fail_openboolTrueIf 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:

ModeBehaviorWhen to Use
"none"No PII scanningInternal tools with no user data
"standard"Scan and warn on all PII typesMost tools -- visibility without blocking
"strict"Scan and block on all PII typesTools that handle sensitive data (email, payments, exports)

PII types detected by the default RegexPIIScanner:

TypeExamples
SSN123-45-6789
Credit card4111-1111-1111-1111
Emailuser@example.com
Phone(555) 123-4567
AWS access keyAKIAIOSFODNN7EXAMPLE
AWS secret keywJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
API keysk-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.

Custom PII scanner

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.

FieldTypeDescription
max_per_minuteintMaximum calls within a 60-second sliding window
max_per_hourintMaximum 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 per-server-process

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):

FieldTypeDefaultDescription
approval_requiredboolFalseWhether the tool call requires approval before execution
pii_scanstr"standard"PII scanning mode: "none", "standard", or "strict"
policy_actionstr | NoneNoneOverride policy action: "allow", "block", or "warn". None uses the server default.
rate_limitdict | NoneNoneRate 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:

ToolConfig SourcePII ScanRate LimitApproval
send_email@governance decoratorstrict5/minYes
query_databasetool_configs constructorstandard (default)500/hrNo
read_fileServer defaultsstandardNoneNo
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