mcp-control-plane
Prevent AI agents from calling dangerous tools in production.
Your MCP server exposes 20 tools to an AI agent. It's 3am. The agent is in a loop, calling refund_customer with $99,999 amounts because the LLM hallucinated a batch operation. Nobody is watching. There's no record of what happened.
mcp-control-plane is the runtime safety layer between your AI agent and your MCP tools. It prevents loops, validates arguments, enforces dry-runs for destructive actions, and records every decision for post-incident replay.
pip install mcp-control-plane
The Problem
The MCP protocol gives you tools/list and tools/call — but says nothing about safety. Every tool call goes straight through.
| Risk | What happens | mcp-control-plane |
|---|---|---|
| Agent loops | 200 calls to the same tool | Tool budgets: max N calls per session/tool |
| Bad arguments | refund($999,999) |
Argument rules: max, min, pattern, one_of |
| Destructive actions | delete_user without thinking |
Dry-run mode: preview first, confirm to execute |
| "What happened?" | No trace after incident | Execution trace: full flight recorder as JSON |
| Wrong permissions | Intern's agent calls admin tools | Role-based access: filter visibility + block calls |
Quick Start
1. Define a safety policy
# policy.yaml
default_role: operator
deny_by_default: true
budgets:
max_calls_per_session: 100
max_calls_per_tool: 20
roles:
operator:
can_see: [read_incident, close_incident, refund_customer]
can_call: [read_incident, close_incident, refund_customer]
admin:
can_see: ["*"]
can_call: ["*"]
tools:
refund_customer:
rules:
amount: { max: 500, min: 0.01 }
reason: { min_length: 10 }
max_calls: 5
delete_user:
require_dry_run: true
2. Protect your server
from mcp.server.mcpserver.server import MCPServer
from mcp_guard import Guard
server = MCPServer("my-server")
# ... define your tools with @server.tool() ...
guard = Guard.from_yaml("policy.yaml", enable_trace=True)
guard.protect(server)
That's it. Every tool call now passes through the safety pipeline.
Features
1. Tool Budgeting — Prevent Agent Loops
Agents loop. An LLM decides "I need to check status" and calls the same tool 200 times. Budgets cap this.
budgets:
max_calls_per_session: 100 # total across all tools
max_calls_per_tool: 20 # default per-tool cap
tools:
expensive_api:
max_calls: 3 # override: stricter limit for costly tools
When exceeded:
tools/call expensive_api → DENIED "Tool budget exceeded: 'expensive_api' called 3/3 times"
2. Argument Validation — Block Dangerous Values
The agent is authorized to call refund, but tries refund(amount=99999). RBAC says yes. The safety policy says no.
tools:
refund_customer:
rules:
amount:
max: 500
min: 0.01
reason:
min_length: 10
restart_service:
rules:
service_name:
pattern: "^[a-z][a-z0-9-]+$" # no spaces, no injection
max_length: 63
set_priority:
rules:
level:
one_of: [low, medium, high, critical]
Available rule types:
| Rule | Type | Example |
|---|---|---|
max |
number | { max: 500 } |
min |
number | { min: 0.01 } |
equals |
any | { equals: true } |
min_length |
string | { min_length: 10 } |
max_length |
string | { max_length: 255 } |
pattern |
regex | { pattern: "^[a-z]+$" } |
one_of |
list | { one_of: [low, medium, high] } |
3. Dry-Run Mode — Preview Before You Destroy
Dangerous tools require a preview execution before the real thing runs.
tools:
delete_user:
require_dry_run: true
drop_database:
require_dry_run: true
Flow:
# First call — preview only, nothing destructive happens
tools/call delete_user { user_id: "123" }
→ [DRY RUN PREVIEW] User 123 would be deleted. Call again with _dry_run_confirm: true to execute.
# Second call — confirmed, executes for real
tools/call delete_user { user_id: "123", _dry_run_confirm: true }
→ User 123 permanently deleted
The agent sees what would happen before it happens. Audit log records both the preview and the confirmation.
4. Execution Trace — The Flight Recorder
After an incident, you need to know: what did the agent do? The trace records every tool interaction — not just allow/deny, but arguments, results, and timing.
guard = Guard.from_yaml("policy.yaml", enable_trace=True)
guard.protect(server)
# Later, after an incident:
print(guard.export_trace())
Output:
[
{
"timestamp": "2026-06-15T10:00:00+00:00",
"tool": "refund_customer",
"arguments": {"customer_id": "C-789", "amount": 99999, "reason": "batch"},
"decision": "VALIDATION_FAILED",
"role": "operator",
"violations": ["amount: value 99999 exceeds maximum 500.0"]
},
{
"timestamp": "2026-06-15T10:00:01+00:00",
"tool": "refund_customer",
"arguments": {"customer_id": "C-789", "amount": 50, "reason": "customer complaint about service"},
"decision": "ALLOW",
"role": "operator",
"duration_ms": 145.2
},
{
"timestamp": "2026-06-15T10:00:02+00:00",
"tool": "delete_user",
"arguments": {"user_id": "U-456"},
"decision": "DRY_RUN_PREVIEW",
"role": "operator"
}
]
5. Role-Based Access Control
Different roles see different tools. Same server, different views.
roles:
viewer:
can_see: [read_incident, search_kb]
can_call: [read_incident, search_kb]
operator:
can_see: [read_incident, close_incident, refund_customer]
can_call: [read_incident, close_incident, refund_customer]
admin:
can_see: ["*"]
can_call: ["*"]
Two layers of enforcement:
- tools/list: filter what the client sees (cosmetic)
- tools/call: block what the client executes (enforcement)
Even if a client caches the tool list or ignores notifications/tools/list_changed, the call-time check always blocks unauthorized execution.
6. Audit Logging
Every decision is logged as structured JSON:
{"timestamp": "2026-06-15T10:00:00+00:00", "action": "tools/call", "tool": "refund_customer", "role": "operator", "decision": "VALIDATION_FAILED", "reason": "amount: value 99999 exceeds maximum 500.0"}
{"timestamp": "2026-06-15T10:00:01+00:00", "action": "tools/call", "tool": "delete_user", "role": "operator", "decision": "DRY_RUN_PREVIEW"}
{"timestamp": "2026-06-15T10:00:02+00:00", "action": "tools/call", "tool": "read_incident", "role": "viewer", "decision": "ALLOW"}
Decision types: ALLOW, DENY, BUDGET_EXCEEDED, VALIDATION_FAILED, DRY_RUN_PREVIEW, DRY_RUN_CONFIRMED
Custom loggers:
# JSON to stderr (default)
guard = Guard.from_yaml("policy.yaml")
# Python standard logging
from mcp_guard import PythonLoggingAuditLogger
guard = Guard.from_yaml("policy.yaml", audit_logger=PythonLoggingAuditLogger())
# Custom — send to Datadog, Splunk, wherever
class MyLogger:
def log(self, event):
send_to_datadog(event.to_dict())
guard = Guard.from_yaml("policy.yaml", audit_logger=MyLogger())
Safety Pipeline
Every tools/call passes through this pipeline in order:
1. RBAC check → DENY if role cannot call this tool
2. Budget check → DENY if session or tool budget exceeded
3. Argument validation → DENY if arguments violate safety rules
4. Dry-run check → PREVIEW if tool requires confirmation
5. Execute tool
6. Record trace → full execution record for replay
7. Record budget → increment call counters
Each layer is independent. If RBAC denies, budget is never checked. If budget exceeds, arguments are never validated. Fast-fail, minimal overhead.
Role Resolvers
How does mcp-control-plane know who's calling?
# HTTP header (default)
Guard.from_yaml("policy.yaml", resolver=HeaderResolver("x-mcp-role"))
# JWT token claim
Guard.from_yaml("policy.yaml", resolver=JWTResolver(claim="role", secret="..."))
# API key mapping
Guard.from_yaml("policy.yaml", resolver=APIKeyResolver({"sk-abc": "admin", "sk-xyz": "viewer"}))
# Static (testing)
Guard.from_yaml("policy.yaml", resolver=StaticResolver("admin"))
# Custom function
Guard.from_yaml("policy.yaml", resolver=CallableResolver(lambda ctx: lookup_role(ctx)))
Integration Paths
MCPServer (FastMCP) — Recommended
from mcp.server.mcpserver.server import MCPServer
from mcp_guard import Guard
server = MCPServer("my-server")
guard = Guard.from_yaml("policy.yaml")
guard.protect(server)
Wraps list_tools() and call_tool() — public, stable API that survives SDK upgrades.
Low-level Server — Full Per-Request Context
from mcp.server.lowlevel.server import Server
from mcp_guard import Guard
server = Server("my-server", on_list_tools=..., on_call_tool=...)
guard = Guard.from_yaml("policy.yaml")
server.middleware.append(guard.as_middleware())
Uses the SDK's ServerMiddleware protocol. Full access to per-request headers and session for role resolution.
Architecture
┌───────────────────────────────────────────────────────┐
│ MCP Client │
│ (Claude, Cursor, etc.) │
└───────────────────────┬───────────────────────────────┘
│ JSON-RPC
┌───────────────────────▼───────────────────────────────┐
│ mcp-control-plane │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ RBAC │→│ Budget │→│ Argument │→│ Dry-Run │ │
│ │ Filter │ │ Limits │ │ Validate │ │ Enforce │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Audit Logger │ │ Execution Trace │ │
│ │ (every decision) │ │ (args + results + time) │ │
│ └──────────────────┘ └──────────────────────────┘ │
└───────────────────────┬───────────────────────────────┘
│
┌───────────────────────▼───────────────────────────────┐
│ MCP Server │
│ (your tools, untouched) │
└───────────────────────────────────────────────────────┘
Policy Reference
# Defaults when role cannot be resolved
default_role: viewer
deny_by_default: true
# Global call budgets (optional)
budgets:
max_calls_per_session: 100 # total across all tools
max_calls_per_tool: 20 # default per-tool cap
# Role-based access (optional, but recommended)
roles:
<role_name>:
can_see: [tool1, tool2, "prefix_*", "*"]
can_call: [tool1, "prefix_*"]
# Per-tool safety rules (optional)
tools:
<tool_name>:
max_calls: 5 # overrides budgets.max_calls_per_tool
require_dry_run: true # must preview before executing
rules:
<argument_name>:
max: 500 # numeric maximum
min: 0 # numeric minimum
equals: true # exact match
min_length: 10 # string minimum length
max_length: 255 # string maximum length
pattern: "^[a-z]+$" # regex match
one_of: [a, b, c] # allowed values
All sections are optional. An empty policy file with just deny_by_default: false allows everything through.
Installation
pip install mcp-control-plane # core
pip install mcp-control-plane[jwt] # + JWT role resolver
pip install mcp-control-plane[otel] # + OpenTelemetry tracing
pip install mcp-control-plane[dev] # + dev tools
Contributing
Contributions welcome. Please open an issue first to discuss what you'd like to change.
No comments yet
Be the first to share your take.