MCP Approval Workflows
When a policy blocks an MCP tool call, the default behavior is to raise a PolicyViolationError and prevent the tool from executing. But sometimes you want a human to decide -- should this blocked call go through anyway? Approval workflows let you intercept blocked calls, present them to a human (or an automated system), and proceed based on the decision.
In this guide, you'll learn how to configure approval handlers for MCP tool calls, use the built-in handlers for common scenarios, and build custom handlers that integrate with Slack, webhooks, or any approval channel you need.
How Approval Works
Here's the flow when a tool call is blocked and an approval handler is configured:
If the handler returns approved=True, the tool executes normally. If approved=False or the approval times out, the PolicyViolationError propagates to the caller.
The entire approval lifecycle is recorded on the span -- when the approval was requested, who approved (or denied), and how long the decision took.
Built-in Approval Handlers
Waxell provides three built-in handlers that cover the most common scenarios. They're available as top-level convenience functions on the waxell_observe module.
prompt_approval -- Terminal Prompt
An interactive terminal prompt that displays the block reason, approvers, and timeout window, then waits for a y/n response:
import waxell_observe as waxell
from waxell_observe.instrumentors.mcp_instrumentor import configure_session
waxell.init()
# After session.initialize():
configure_session(
session,
server_name="filesystem",
on_policy_block=waxell.prompt_approval,
governance_config={
"agent_name": "file-manager",
"user_id": "user-123",
},
)
When a block triggers, the terminal shows:
!! BLOCKED BY APPROVAL POLICY
Reason: File writes require approval
Operation: write_file
Approvers: ops-team@company.com
Timeout: 5 min
Approve this operation? (y/n):
If the user types y, the tool executes. If n, Ctrl+C, or the timeout expires, the PolicyViolationError propagates.
auto_approve -- Always Approve
Always approves blocked calls without prompting. Use this for testing and dry-run scenarios where you want to exercise the approval code path without human interaction:
configure_session(
session,
server_name="filesystem",
on_policy_block=waxell.auto_approve,
governance_config={"agent_name": "file-manager"},
)
auto_deny -- Always Deny
Always denies blocked calls. Use this in testing to verify that your agent handles denied approvals correctly:
configure_session(
session,
server_name="filesystem",
on_policy_block=waxell.auto_deny,
governance_config={"agent_name": "file-manager"},
)
Step 1: Set Up a Policy That Blocks
Before approval workflows can trigger, you need a block policy in the Waxell controlplane. See Policy Configuration for the full guide on creating policies.
For this example, create a policy with:
- Workflow pattern:
mcp:filesystem:write_file - Action:
block - Reason: "File writes require approval"
Step 2: Configure the Approval Handler
Pass your chosen handler to configure_session() via the on_policy_block parameter:
import waxell_observe as waxell
from waxell_observe.instrumentors.mcp_instrumentor import configure_session
waxell.init()
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
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()
configure_session(
session,
server_name="filesystem",
on_policy_block=waxell.prompt_approval,
governance_config={
"agent_name": "file-manager",
"user_id": "user-123",
},
)
# This call triggers the approval flow if a block policy exists
result = await session.call_tool("write_file", {
"path": "/tmp/allowed/output.txt",
"content": "Hello from governed agent!",
})
The on_policy_block handler only triggers when a policy returns action: block. Other actions (allow, warn, throttle) do not invoke the handler.
Step 3: Build a Custom Approval Handler
For production systems, you'll typically need a custom handler that integrates with your team's approval channel. A custom handler is any function (sync or async) that:
- Receives a
PolicyViolationErroras its argument - Returns an
ApprovalDecision
from waxell_observe.types import ApprovalDecision
from waxell_observe.errors import PolicyViolationError
from waxell_observe.approval import extract_block_metadata
async def my_custom_handler(error: PolicyViolationError) -> ApprovalDecision:
"""Custom approval handler with your business logic."""
meta = extract_block_metadata(error)
print(f"Tool blocked: {meta['reason']}")
print(f"Operation: {meta['action_type']}")
print(f"Approvers: {meta['approvers']}")
# Your approval logic here -- call an API, send a message, etc.
approved = await check_with_approval_system(meta)
return ApprovalDecision(
approved=approved,
approver="custom-system",
elapsed_seconds=0.5,
)
The ApprovalDecision Object
Your handler must return an ApprovalDecision with these fields:
| Field | Type | Default | Description |
|---|---|---|---|
approved | bool | required | Whether to proceed with tool execution |
approver | str | "" | Who approved (email, username, system name) |
timed_out | bool | False | Whether the approval window expired |
elapsed_seconds | float | None | Time from block to decision |
from waxell_observe.types import ApprovalDecision
# Approved
ApprovalDecision(approved=True, approver="jane@company.com", elapsed_seconds=12.3)
# Denied
ApprovalDecision(approved=False, elapsed_seconds=5.0)
# Timed out
ApprovalDecision(approved=False, timed_out=True, elapsed_seconds=300.0)
The extract_block_metadata Helper
Use extract_block_metadata() to pull structured data from the PolicyViolationError:
from waxell_observe.approval import extract_block_metadata
meta = extract_block_metadata(error)
meta["reason"] # "File writes require approval"
meta["action_type"] # "write_file" (or workflow type from policy metadata)
meta["approvers"] # ["ops-team@company.com"]
meta["timeout_minutes"] # 5.0 or None
This helper safely handles cases where the error has no policy_result attached.
Integration Patterns
Slack Approval
Send a Slack message and wait for a reaction or button click:
from waxell_observe.types import ApprovalDecision
from waxell_observe.approval import extract_block_metadata
async def slack_approval(error):
meta = extract_block_metadata(error)
# Send approval request to Slack
message = await slack_client.post_message(
channel="#mcp-approvals",
text=(
f"*MCP Tool Blocked*\n"
f"Reason: {meta['reason']}\n"
f"Operation: {meta['action_type']}\n"
f"Approvers: {', '.join(meta['approvers']) or 'any'}"
),
actions=["approve", "deny"],
)
# Wait for someone to click a button
response = await slack_client.wait_for_action(
message_id=message.id,
timeout=meta.get("timeout_minutes", 5) * 60,
)
return ApprovalDecision(
approved=(response.action == "approve"),
approver=response.user_email,
elapsed_seconds=response.elapsed,
)
Logging-Only (Audit Without Blocking)
Log the blocked call without actually preventing execution. This is useful for a trial period before enforcing strict policies:
import logging
from waxell_observe.types import ApprovalDecision
from waxell_observe.approval import extract_block_metadata
logger = logging.getLogger("mcp.audit")
def audit_only_handler(error):
meta = extract_block_metadata(error)
logger.warning(
"WOULD HAVE BLOCKED: %s (reason: %s, approvers: %s)",
meta["action_type"],
meta["reason"],
meta["approvers"],
)
# Always approve -- just logging
return ApprovalDecision(approved=True, approver="audit_bypass")
Argument-Based Auto-Approval
Approve or deny based on the tool call's specific arguments:
from waxell_observe.types import ApprovalDecision
def argument_based_handler(error):
# Access the policy result metadata
policy_result = error.policy_result
metadata = getattr(policy_result, "metadata", {}) or {}
# Example: auto-approve writes to /tmp but deny writes to /etc
workflow_name = metadata.get("workflow_name", "")
if "/tmp/" in str(error):
return ApprovalDecision(approved=True, approver="auto_tmp_rule")
return ApprovalDecision(approved=False)
Time-Based Approval
Auto-approve during business hours, require manual approval outside:
from datetime import datetime
from waxell_observe.types import ApprovalDecision
from waxell_observe.approval import extract_block_metadata
def business_hours_handler(error):
now = datetime.now()
is_business_hours = (
now.weekday() < 5 # Monday-Friday
and 9 <= now.hour < 17 # 9 AM - 5 PM
)
if is_business_hours:
return ApprovalDecision(approved=True, approver="business_hours_auto")
meta = extract_block_metadata(error)
print(f"\n Outside business hours. Manual approval needed.")
print(f" Reason: {meta['reason']}")
answer = input(" Approve? (y/n): ").strip().lower()
return ApprovalDecision(
approved=(answer in ("y", "yes")),
approver="manual_after_hours",
)
What Appears in Traces
When an approval handler runs, the auto-instrumentor records several attributes on the MCP tool call span:
| Attribute | Description | Example |
|---|---|---|
waxell.mcp.governance_action | The policy action that triggered approval | "block" |
waxell.mcp.approval_status | The outcome of the approval | "approved", "denied", "timeout" |
waxell.mcp.approval_approver | Who approved the call | "jane@company.com" |
waxell.mcp.approval_elapsed_ms | How long the approval took (milliseconds) | 12300 |
If no on_policy_block handler is configured and the policy blocks, the span shows waxell.mcp.approval_status = "not_required" -- meaning no approval was attempted before raising the error.
Both sync and async handlers work with on_policy_block. Async handlers are await-ed automatically by the wrapper. This is useful for handlers that need to make network calls (Slack API, webhook, etc.).
Full Example
Full runnable example: Filesystem server with custom approval handler
import asyncio
import waxell_observe as waxell
from waxell_observe.instrumentors.mcp_instrumentor import configure_session
from waxell_observe.errors import PolicyViolationError
from waxell_observe.types import ApprovalDecision
from waxell_observe.approval import extract_block_metadata
# Initialize Waxell (auto-instruments MCP)
waxell.init()
# Import MCP after init()
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
def review_handler(error: PolicyViolationError) -> ApprovalDecision:
"""Custom handler that shows details and asks for confirmation."""
meta = extract_block_metadata(error)
print()
print(" === MCP Tool Call Review ===")
print(f" Reason: {meta['reason']}")
print(f" Operation: {meta['action_type']}")
approvers = meta.get("approvers", [])
if approvers:
print(f" Approvers: {', '.join(approvers)}")
print()
answer = input(" Allow this tool call? (y/n): ").strip().lower()
return ApprovalDecision(
approved=(answer in ("y", "yes")),
approver="interactive_reviewer",
)
async def main():
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 with custom approval handler
configure_session(
session,
server_name="filesystem",
on_policy_block=review_handler,
governance_config={
"agent_name": "file-manager",
"user_id": "demo-user",
},
)
# Read (likely allowed by policy)
try:
result = await session.call_tool(
"read_file",
{"path": "/tmp/allowed/readme.txt"},
)
print(f"Read succeeded: {result.content[0].text[:80]}")
except PolicyViolationError as e:
print(f"Read blocked: {e}")
# Write (may trigger approval if block policy exists)
try:
result = await session.call_tool(
"write_file",
{
"path": "/tmp/allowed/output.txt",
"content": "Written after approval!",
},
)
print(f"Write succeeded: {result.content[0].text}")
except PolicyViolationError as e:
print(f"Write denied: {e}")
asyncio.run(main())
Troubleshooting
Approval handler never triggers
Symptom: Tool calls are blocked with PolicyViolationError but the on_policy_block handler is never called.
Cause: The on_policy_block parameter was not passed to configure_session(), or it was set to None.
Fix: Ensure you pass the handler explicitly:
configure_session(
session,
server_name="filesystem",
on_policy_block=waxell.prompt_approval, # or your custom handler
governance_config={"agent_name": "my-agent"},
)
Handler runs but tool still doesn't execute
Symptom: The approval handler is called and returns approved=True, but the tool call still raises PolicyViolationError.
Cause: Your handler might not be returning an ApprovalDecision object. If the handler returns a non-ApprovalDecision value, it's coerced with bool() -- which could fail for complex objects.
Fix: Always return an explicit ApprovalDecision:
from waxell_observe.types import ApprovalDecision
def my_handler(error):
# Don't return True/False directly
return ApprovalDecision(approved=True, approver="my-system")
Async handler raises TypeError
Symptom: TypeError: object coroutine can't be used in sync context or similar.
Cause: An async handler was used in a context that expects sync execution.
Fix: The MCP auto-instrumentor always runs in an async context (since call_tool is async), so async handlers work correctly. If you see this error, it may be from using the same handler with the sync WaxellContext -- use a sync handler there instead.
Approval attributes missing from spans
Symptom: The span has waxell.mcp.governance_action = "block" but no waxell.mcp.approval_status attribute.
Cause: The on_policy_block handler raised an exception, or the approval lifecycle recording failed silently.
Fix: Ensure your handler doesn't raise exceptions. Wrap your handler logic in a try/except and return a denial on error:
from waxell_observe.types import ApprovalDecision
def safe_handler(error):
try:
# Your approval logic
return ApprovalDecision(approved=True, approver="system")
except Exception as e:
print(f"Approval error: {e}")
return ApprovalDecision(approved=False)
No policy_result on the error
Symptom: error.policy_result is None inside your handler, and extract_block_metadata() returns generic values.
Cause: The block was triggered by PII scanning or security checks rather than a policy rule. In these cases, the policy_result may not be populated.
Fix: Use extract_block_metadata() which safely handles missing policy results, falling back to the error message for the reason field.
Next Steps
- Policy Configuration -- Set up allowlist/blocklist policies for MCP tools
- PII Scanning -- Scan tool inputs and outputs for sensitive data
- Rug Pull Detection -- Detect tool definition changes
- Reference -- All
waxell.mcp.*span attributes