Skip to main content

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 NameTool NamePolicy ID
composioGITHUB_CREATE_ISSUEmcp:composio:GITHUB_CREATE_ISSUE
filesystemwrite_filemcp:filesystem:write_file
filesystemread_filemcp:filesystem:read_file
my-apidelete_recordmcp: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.

Why the mcp: prefix?

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:

KeyTypeDefaultDescription
agent_namestr""Identifies the agent in the audit trail and policy matching
user_idstr""Identifies the user in the audit trail
scan_inputsboolTrueEnable PII scanning on tool arguments (see PII Scanning)
scan_outputsboolTrueEnable PII scanning on tool results (see PII Scanning)
pii_scannerPIIScannerNoneCustom PII scanner instance
pii_actionsdictNonePer-type PII action overrides (e.g., {"ssn": "block", "email": "warn"})
block_modestr"raise"What happens when a tool is blocked: "raise" or "error_result"
note

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:

  1. Navigate to Governance > Policies in the Waxell dashboard
  2. Click Create Policy
  3. Set the Workflow pattern to an mcp:{server}:{tool} value -- for example, mcp:filesystem:write_file
  4. Choose an Action: allow, block, warn, or throttle
  5. Optionally add conditions, approvers, or a human-readable reason
  6. Save the policy
Pattern matching

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_block handler): A PolicyViolationError is raised
  • With on_policy_block handler: 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 PatternActionReason
mcp:filesystem:write_fileblockFile writes require approval
mcp:filesystem:create_directoryblockDirectory creation restricted
mcp:filesystem:move_fileblockFile 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 PatternActionMetadata
mcp:composio:GITHUB_CREATE_ISSUEthrottle{"throttle_delay_seconds": 2}
mcp:composio:SLACK_SEND_MESSAGEthrottle{"throttle_delay_seconds": 1}

Warn on Sensitive Data Access

Monitor access to sensitive tools without blocking them:

Workflow PatternActionReason
mcp:database:query_userswarnAccessing user PII data
mcp:database:export_tablewarnBulk 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 PatternActionReason
mcp:database:drop_tableblockDestructive operation requires approval
mcp:database:delete_recordsblockBulk 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:

AttributeDescription
waxell.mcp.policy_idThe mcp:{server}:{tool} identifier used for policy matching
waxell.mcp.governance_checkedtrue if a policy check was performed
waxell.mcp.governance_actionThe policy result: allow, block, warn, or throttle
waxell.mcp.policy_reasonHuman-readable reason from the policy engine
waxell.mcp.governance_timestampISO 8601 timestamp of the policy check
waxell.mcp.audit_agentThe agent_name from your governance_config
waxell.mcp.audit_userThe user_id from your governance_config
waxell.mcp.params_hashSHA256 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_name passed to configure_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