MCP Policy Configuration
In this guide, you'll learn how to control which MCP tools your agent can call using Waxell's policy engine. Policies are rules that run before every tool call to decide whether it should be allowed, blocked, warned about, or throttled. Because policies live on the Waxell controlplane rather than in your agent code, administrators can change what's permitted without redeploying any agents.
By the end of this guide, you'll understand how to configure governance on an MCP session, create policies that target specific MCP tools using the mcp:{server}:{tool} naming convention, and handle the different outcomes when a policy triggers.
How MCP Policies Work
When the auto-instrumentor wraps a call_tool() call, it constructs a policy ID from the server name and tool name using the format mcp:{server}:{tool}. This policy ID is sent to the Waxell controlplane for evaluation against your configured policies.
Here are some concrete examples of how policy IDs are constructed:
| Server Name | Tool Name | Policy ID |
|---|---|---|
composio | GITHUB_CREATE_ISSUE | mcp:composio:GITHUB_CREATE_ISSUE |
filesystem | write_file | mcp:filesystem:write_file |
filesystem | read_file | mcp:filesystem:read_file |
my-api | delete_record | mcp:my-api:delete_record |
If no server_name is configured on the session, the policy ID becomes mcp::{tool} (with an empty server segment). This still works for policy matching, but providing a server name gives you more precise targeting.
The mcp: prefix distinguishes MCP tool call policies from regular agent workflow policies. This lets you have policies that apply specifically to MCP tools without affecting your non-MCP agent workflows.
Prerequisites
Before you begin, make sure you have:
- A Waxell account with an API key (from your Waxell controlplane dashboard)
- Python 3.10+
- The waxell-observe package with MCP extras installed:
pip install "waxell-observe[mcp]" - An MCP server to connect to (this guide uses the filesystem MCP server for examples)
Step 1: Enable Governance on a Session
Governance activates when you call configure_session() with a governance_config dictionary. Without this configuration, tool calls are still traced but no policy checks run.
import waxell_observe as waxell
from waxell_observe.instrumentors.mcp_instrumentor import configure_session
waxell.init()
# After creating and initializing your MCP session:
configure_session(
session,
server_name="filesystem",
governance_config={
"agent_name": "file-manager",
"user_id": "user-123",
},
)
The governance_config dictionary supports these keys:
| Key | Type | Default | Description |
|---|---|---|---|
agent_name | str | "" | Identifies the agent in the audit trail and policy matching |
user_id | str | "" | Identifies the user in the audit trail |
scan_inputs | bool | True | Enable PII scanning on tool arguments (see PII Scanning) |
scan_outputs | bool | True | Enable PII scanning on tool results (see PII Scanning) |
pii_scanner | PIIScanner | None | Custom PII scanner instance |
pii_actions | dict | None | Per-type PII action overrides (e.g., {"ssn": "block", "email": "warn"}) |
block_mode | str | "raise" | What happens when a tool is blocked: "raise" or "error_result" |
configure_session() must be called after session.initialize() and before making any tool calls. It sets internal attributes on the session that the auto-instrumentor reads during each call_tool() invocation.
Step 2: Create a Policy in the Dashboard
Policies are created in the Waxell controlplane, not in your agent code. This separation means administrators can change what's permitted without redeploying agents, and policy changes take effect immediately.
To create a policy that targets MCP tool calls:
- Navigate to Governance > Policies in the Waxell dashboard
- Click Create Policy
- Set the Workflow pattern to an
mcp:{server}:{tool}value -- for example,mcp:filesystem:write_file - Choose an Action: allow, block, warn, or throttle
- Optionally add conditions, approvers, or a human-readable reason
- Save the policy
You can create policies that match multiple tools by using the workflow pattern field. For example, a policy with pattern mcp:filesystem:write_file matches exactly that one tool, while broader patterns can target all tools on a server.
You can also create policies via the REST API:
curl -X POST https://acme.waxell.dev/waxell/v1/policies/ \
-H "Authorization: Bearer $WAXELL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workflow_name": "mcp:filesystem:write_file",
"action": "block",
"reason": "File writes require approval",
"category": "scope"
}'
Policy Actions
Every policy check returns one of four actions. Here's what each means for MCP tool calls:
Allow
The tool executes normally. The policy check and its result are recorded in the audit trail on the span, but no intervention occurs.
# Tool executes, span records:
# waxell.mcp.governance_action = "allow"
# waxell.mcp.governance_checked = True
result = await session.call_tool("read_file", {"path": "/tmp/data.txt"})
Block
The tool is prevented from executing. What happens next depends on your configuration:
- Default (no
on_policy_blockhandler): APolicyViolationErroris raised - With
on_policy_blockhandler: The approval workflow triggers (see Approval Workflows) - With
block_mode: "error_result": An MCP error result is returned instead of raising an exception
# waxell.mcp.governance_action = "block"
# waxell.mcp.approval_status = "not_required" (no handler configured)
Warn
The tool executes normally, but a warning is logged and recorded on the span. This is useful for monitoring tools that you may want to restrict in the future.
# Tool executes, but span records:
# waxell.mcp.governance_action = "warn"
# A warning is also logged: "MCP governance warning for filesystem:write_file -- ..."
result = await session.call_tool("write_file", {"path": "/tmp/log.txt", "content": "data"})
Throttle
The tool execution is delayed by the amount specified in the policy's throttle_delay_seconds metadata. The delay is capped at 30 seconds to prevent abuse. After the delay, the tool executes normally.
# Tool is delayed, then executes. Span records:
# waxell.mcp.governance_action = "throttle"
# waxell.mcp.throttle_delay_ms = 2000 (2 second delay)
result = await session.call_tool("search", {"query": "expensive operation"})
Common Policy Patterns
Here are practical policy configurations for common MCP governance scenarios:
Block All Write Operations on a Filesystem Server
Create policies for each write tool:
| Workflow Pattern | Action | Reason |
|---|---|---|
mcp:filesystem:write_file | block | File writes require approval |
mcp:filesystem:create_directory | block | Directory creation restricted |
mcp:filesystem:move_file | block | File moves require approval |
Read operations (read_file, list_directory, search_files) remain allowed since no blocking policy targets them.
Throttle Expensive API Calls
For tools that call rate-limited or costly external APIs:
| Workflow Pattern | Action | Metadata |
|---|---|---|
mcp:composio:GITHUB_CREATE_ISSUE | throttle | {"throttle_delay_seconds": 2} |
mcp:composio:SLACK_SEND_MESSAGE | throttle | {"throttle_delay_seconds": 1} |
Warn on Sensitive Data Access
Monitor access to sensitive tools without blocking them:
| Workflow Pattern | Action | Reason |
|---|---|---|
mcp:database:query_users | warn | Accessing user PII data |
mcp:database:export_table | warn | Bulk data export detected |
Block Destructive Operations with Approval
Combine a block policy with an on_policy_block handler to require human approval for destructive operations:
| Workflow Pattern | Action | Reason |
|---|---|---|
mcp:database:drop_table | block | Destructive operation requires approval |
mcp:database:delete_records | block | Bulk deletion requires approval |
Then configure the session with an approval handler (see Approval Workflows):
configure_session(
session,
server_name="database",
on_policy_block=waxell.prompt_approval,
governance_config={
"agent_name": "db-manager",
"user_id": "admin-1",
},
)
Handling Blocked Calls
When a policy blocks a tool call, the behavior depends on your configuration. There are three modes:
Default: Raise PolicyViolationError
When no on_policy_block handler is configured and block_mode is "raise" (the default), a PolicyViolationError is raised:
from waxell_observe.errors import PolicyViolationError
try:
result = await session.call_tool("write_file", {
"path": "/tmp/output.txt",
"content": "hello world",
})
except PolicyViolationError as e:
print(f"Blocked: {e}")
# Access the policy result for details
if e.policy_result:
print(f"Action: {e.policy_result.action}")
print(f"Reason: {e.policy_result.reason}")
Return Error as MCP Result
Set block_mode to "error_result" in your governance_config to return a synthetic CallToolResult with isError=True instead of raising an exception. This is useful when your agent framework expects MCP results rather than exceptions:
configure_session(
session,
server_name="filesystem",
governance_config={
"agent_name": "file-manager",
"block_mode": "error_result",
},
)
# No exception raised -- instead returns CallToolResult(isError=True)
result = await session.call_tool("write_file", {
"path": "/tmp/output.txt",
"content": "hello world",
})
if result.isError:
# result.content[0].text == "Tool call blocked by policy: ..."
print(f"Blocked: {result.content[0].text}")
Trigger Approval Workflow
Pass an on_policy_block handler to configure_session() to trigger an approval workflow when a tool is blocked. If the human approves, the tool executes normally. If denied or timed out, the PolicyViolationError propagates.
configure_session(
session,
server_name="filesystem",
on_policy_block=waxell.prompt_approval,
governance_config={
"agent_name": "file-manager",
},
)
For the full guide on building custom approval handlers, see Approval Workflows.
Full Example
Full runnable example: Filesystem server with policy governance
import asyncio
import waxell_observe as waxell
from waxell_observe.instrumentors.mcp_instrumentor import configure_session
from waxell_observe.errors import PolicyViolationError
# Initialize Waxell (auto-instruments MCP)
waxell.init()
# Import MCP after init()
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
# Connect to the filesystem MCP server
server_params = StdioServerParameters(
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp/allowed"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Enable governance
configure_session(
session,
server_name="filesystem",
governance_config={
"agent_name": "file-manager",
"user_id": "demo-user",
"scan_inputs": True,
"scan_outputs": True,
},
)
# This call will be policy-checked as "mcp:filesystem:read_file"
try:
result = await session.call_tool(
"read_file",
{"path": "/tmp/allowed/readme.txt"},
)
print(f"Read result: {result.content[0].text[:100]}")
except PolicyViolationError as e:
print(f"Read blocked: {e}")
# This call will be policy-checked as "mcp:filesystem:write_file"
try:
result = await session.call_tool(
"write_file",
{
"path": "/tmp/allowed/output.txt",
"content": "Hello from governed agent!",
},
)
print(f"Write result: {result.content[0].text}")
except PolicyViolationError as e:
print(f"Write blocked: {e}")
asyncio.run(main())
What to Check in Your Traces
After running an agent with governance enabled, each MCP tool call span includes these governance-related attributes:
| Attribute | Description |
|---|---|
waxell.mcp.policy_id | The mcp:{server}:{tool} identifier used for policy matching |
waxell.mcp.governance_checked | true if a policy check was performed |
waxell.mcp.governance_action | The policy result: allow, block, warn, or throttle |
waxell.mcp.policy_reason | Human-readable reason from the policy engine |
waxell.mcp.governance_timestamp | ISO 8601 timestamp of the policy check |
waxell.mcp.audit_agent | The agent_name from your governance_config |
waxell.mcp.audit_user | The user_id from your governance_config |
waxell.mcp.params_hash | SHA256 hash of tool arguments (truncated to 16 chars) for audit fingerprinting |
If the controlplane was unreachable during the policy check, you'll see waxell.mcp.policy_skipped = true instead. The auto-instrumentor is fail-open by default -- it never blocks a tool call due to its own errors.
For the full list of MCP span attributes, see the Reference page.
Troubleshooting
Policy check not running
Symptom: Tool calls succeed but no waxell.mcp.governance_checked attribute appears on spans.
Cause: governance_config was not set on the session, or configure_session() was called before session.initialize().
Fix: Ensure you call configure_session() after session.initialize() with a governance_config dict:
await session.initialize()
configure_session(session, server_name="myserver", governance_config={"agent_name": "my-agent"})
Policy always allows (never blocks)
Symptom: waxell.mcp.governance_action shows allow even though you created a block policy.
Cause: The policy's workflow pattern doesn't match the tool's policy ID. Check that:
- The server name in your policy matches the
server_namepassed toconfigure_session() - The tool name in your policy matches the exact tool name used in
call_tool() - The policy is active (not disabled or expired)
Debug: Check the waxell.mcp.policy_id attribute on the span. It shows the exact ID used for matching (e.g., mcp:filesystem:write_file). Compare this with your policy's workflow pattern.
PolicyViolationError not caught
Symptom: Your try/except doesn't catch the block.
Fix: Import PolicyViolationError from the correct module:
from waxell_observe.errors import PolicyViolationError
Not from waxell_observe.instrumentors or other submodules.
"Waxell client not configured" warning
Symptom: Log shows "Waxell client not configured -- skipping MCP policy check" and spans show waxell.mcp.policy_skipped = true.
Cause: The Waxell API key or URL is not set.
Fix: Either pass credentials to waxell.init():
waxell.init(api_key="wax_sk_...", api_url="https://acme.waxell.dev")
Or set environment variables:
export WAXELL_API_KEY="wax_sk_..."
export WAXELL_API_URL="https://acme.waxell.dev"
Controlplane unreachable (fail-open)
Symptom: Spans show waxell.mcp.policy_skipped = true and a warning is logged about the policy check failing.
Cause: The controlplane is down or the network connection failed. The auto-instrumentor is fail-open by design -- tools execute normally when the controlplane is unreachable.
Fix: Check your WAXELL_API_URL and network connectivity. The tool call itself is not affected.
Next Steps
- Approval Workflows -- Handle blocked calls with human-in-the-loop approval
- PII Scanning -- Scan tool inputs and outputs for sensitive data
- Rug Pull Detection -- Detect and alert on tool definition changes
- Reference -- All
waxell.mcp.*span attributes