Skip to main content

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!",
})
note

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:

  1. Receives a PolicyViolationError as its argument
  2. 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:

FieldTypeDefaultDescription
approvedboolrequiredWhether to proceed with tool execution
approverstr""Who approved (email, username, system name)
timed_outboolFalseWhether the approval window expired
elapsed_secondsfloatNoneTime 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:

AttributeDescriptionExample
waxell.mcp.governance_actionThe policy action that triggered approval"block"
waxell.mcp.approval_statusThe outcome of the approval"approved", "denied", "timeout"
waxell.mcp.approval_approverWho approved the call"jane@company.com"
waxell.mcp.approval_elapsed_msHow 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.

Async handlers supported

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