Policy Layer MCP Server
Некастодиальные ограничения расходов для кошельков ИИ-агентов — устанавливайте лимиты, белые списки и аварийные выключатели до выполнения транзакций.
Документация
Your AI agent just made its 47th Stripe charge of the day. Each one looked reasonable in isolation — $12 here, $35 there — but the cumulative total hit $4,200 before anyone noticed. The agent was doing exactly what it was told: processing orders. It just never stopped.
Adding spending controls to your MCP agent prevents exactly this scenario. MCP servers like Stripe, AWS, and Twilio give agents direct access to tools that cost real money. The agent doesn’t know it has a budget. The MCP server doesn’t enforce one. And the system prompt saying “don’t spend more than $500 per day” is a suggestion, not a constraint.
PolicyLayer solves this by sitting between the agent and the MCP server as a transparent proxy. Every tools/call request passes through it, gets evaluated against a YAML policy file, and is either forwarded or blocked. The agent doesn’t know PolicyLayer exists — same tools, same schemas, same interface.
Here’s how to set it up.
The Architecture
┌──────────┐ ┌─────────────┐ ┌────────────┐
│ LLM/AI │──────>│ PolicyLayer │──────>│ MCP Server │
│ Client │<──────│ (proxy) │<──────│ (upstream) │
└──────────┘ └─────────────┘ └────────────┘
│
┌────┴────┐
│ Policy │
│ Engine │
└────┬────┘
┌────┴────┐
│ State │
│ Store │
└─────────┘
PolicyLayer proxies MCP traffic over HTTP. It intercepts tools/call requests, evaluates them against your policy, and returns a denial message if any rule fails. The state store persists counters across restarts so your daily spend caps survive process recycling.
Step 1: Scan the MCP Server
Before writing policies, you need to know what tools are available. PolicyLayer connects to any registered MCP server, discovers its tools, and generates a commented YAML scaffold listing every tool with its parameters, grouped by category. It’s a starting point — everything is allowed by default until you add rules.
Step 2: Add a Per-Transaction Limit
The most basic spending control is capping a single transaction. If your agent can call create_charge, you probably don’t want it creating $10,000 charges:
version: "1"
description: "Stripe spending controls"
tools:
create_charge:
rules:
- name: "max single charge"
conditions:
- path: "args.amount"
op: "lte"
value: 50000
on_deny: "Single charge cannot exceed $500.00"
This rule checks the amount argument on every create_charge call. If it exceeds 50000 (Stripe uses cents), the call is blocked and the agent receives the denial message. The agent can then decide what to do — ask the user for approval, split the transaction, or abandon the task.
The key detail: this check happens at the transport layer, before the request reaches Stripe. The charge is never created. There’s no refund to process, no failed payment to reconcile. This is deterministic policy enforcement — the same input always produces the same result.
Step 3: Add a Daily Spend Cap
Per-transaction limits don’t prevent accumulation. An agent making 200 charges of $50 each will sail past a $500 single-charge limit while racking up $10,000 in total spend. You need cumulative tracking.
PolicyLayer handles this with stateful counters:
tools:
create_charge:
rules:
- name: "max single charge"
conditions:
- path: "args.amount"
op: "lte"
value: 50000
on_deny: "Single charge cannot exceed $500.00"
- name: "daily spend cap"
conditions:
- path: "state.create_charge.daily_spend"
op: "lte"
value: 1000000
on_deny: "Daily spending cap of $10,000.00 reached"
state:
counter: "daily_spend"
window: "day"
increment_from: "args.amount"
The state block creates a counter called daily_spend that resets at midnight UTC. On each allowed create_charge call, the counter increments by whatever args.amount is. Before the next call, the condition checks whether the cumulative total exceeds the limit.
The increment_from field is what makes this work for spending specifically. Instead of counting calls (the default), it sums the actual dollar amounts. A $50 charge increments by 5000, a $200 charge by 20000. When the running total would exceed 1000000 ($10,000), further charges are denied.
Counters persist in the state store. If you restart PolicyLayer, the daily total picks up where it left off. And the two-phase model means failed upstream calls don’t consume quota — if Stripe returns an error, the increment is rolled back.
Step 4: Restrict Currencies and Arguments
Spending controls aren’t just about amounts. You might want to restrict which currencies an agent can charge in, which regions it can operate in, or which products it can purchase:
- name: "allowed currencies"
conditions:
- path: "args.currency"
op: "in"
value: ["usd", "eur"]
on_deny: "Only USD and EUR charges are permitted"
This uses the in operator to check against a whitelist. You can combine multiple conditions in a single rule — they’re ANDed together:
- name: "safe charge"
conditions:
- path: "args.amount"
op: "lte"
value: 50000
- path: "args.currency"
op: "in"
value: ["usd", "eur"]
on_deny: "Charge must be under $500 and in USD or EUR"
Both conditions must pass. If either fails, the entire call is denied.
Step 5: Block Destructive Operations
Some tools should never be called by an agent, regardless of arguments. Deleting customers, dropping databases, removing infrastructure — these are human-only operations:
hide:
- delete_customer
- delete_product
- delete_invoice
tools:
delete_subscription:
rules:
- name: "block subscription deletion"
action: "deny"
on_deny: "Subscription deletion is not permitted via AI agents"
There are two approaches here. The hide list removes tools from the agent’s view entirely — they’re stripped from tools/list responses, so the agent never knows they exist. This saves context window tokens and prevents the agent from even attempting the call.
For tools you want the agent to see but not use, use action: "deny". The tool shows up in tools/list, but any call is unconditionally blocked with the denial message.
Step 6: Add a Global Rate Limit
Even with per-tool spending controls, you want a backstop. A global rate limit caps the total number of tool calls per time window across all tools:
"*":
rules:
- name: "global rate limit"
rate_limit: 60/minute
The "*" wildcard applies to every tool call. This prevents runaway loops where an agent calls tools hundreds of times per minute, regardless of whether each individual call passes its specific rules. For more on rate limiting strategies, see our practical guide.
Step 7: Wire It Up
Route your Stripe MCP server through PolicyLayer — point your MCP client at the gateway URL with a per-person grant token, and the policy above runs on every call before it reaches Stripe:
{
"mcpServers": {
"stripe": {
"url": "https://proxy.policylayer.com/mcp/<server-uuid>/",
"headers": { "Authorization": "Bearer <grant-token>" }
}
}
}
You define and adjust the policy in the PolicyLayer dashboard — no local proxy to install or run.
The agent connects to PolicyLayer thinking it’s the Stripe MCP server. PolicyLayer forwards everything except policy violations.
The Complete Policy
Here’s the full policy combining all the rules above:
Click to expand the complete policy YAML
version: "1"
description: "Stripe MCP server spending controls"
hide:
- delete_customer
- delete_product
- delete_invoice
tools:
create_charge:
rules:
- name: "max single charge"
conditions:
- path: "args.amount"
op: "lte"
value: 50000
on_deny: "Single charge cannot exceed $500.00"
- name: "daily spend cap"
conditions:
- path: "state.create_charge.daily_spend"
op: "lte"
value: 1000000
on_deny: "Daily spending cap of $10,000.00 reached"
state:
counter: "daily_spend"
window: "day"
increment_from: "args.amount"
- name: "allowed currencies"
conditions:
- path: "args.currency"
op: "in"
value: ["usd", "eur"]
on_deny: "Only USD and EUR charges are permitted"
create_refund:
rules:
- name: "refund amount cap"
conditions:
- path: "args.amount"
op: "lte"
value: 10000
on_deny: "Refunds over $100.00 require manual processing"
- name: "daily refund count"
rate_limit: 10/day
on_deny: "Daily refund limit (10) reached"
"*":
rules:
- name: "global rate limit"
rate_limit: 60/minute
Hot Reload
Policies are hot-reloadable. Edit the policy in the dashboard while PolicyLayer is running and changes apply immediately — no restart, no dropped connections. This means you can tighten limits in response to observed behaviour without interrupting the agent.
PolicyLayer also validates policies before they go live, catching syntax errors, invalid operators, missing counters, and logical conflicts before they hit production.
What the Agent Sees
When a call is denied, the agent receives a message like:
[POLICYLAYER POLICY DENIED] Daily spending cap of $10,000.00 reached
This is deliberate. The agent knows why the call failed and can adapt its behaviour — inform the user, try a smaller amount, or wait until the window resets. It’s a feedback loop, not a silent failure.
Beyond Stripe
The same pattern works for any MCP server that touches money or resources. AWS cost controls, Twilio message limits, database write caps, API call budgets — if the tool has arguments you can validate and calls you can count, PolicyLayer can enforce limits on it.
FAQ
How do MCP spending controls persist across restarts?
PolicyLayer stores counter state in a persistent state store. When you restart PolicyLayer, daily spend totals, rate limit counters, and all other stateful tracking picks up exactly where it left off. No state is lost.
Can MCP agents bypass spending limits?
Not through PolicyLayer. Because spending controls are enforced at the transport layer — between the agent and the MCP server — the agent has no way to bypass them. The agent doesn’t even know PolicyLayer exists. It sees the same tools and schemas, but every tools/call request is evaluated against the policy before reaching the upstream server.
What happens when an MCP agent hits a spending limit?
The agent receives a denial message explaining why the call was blocked, e.g. [POLICYLAYER POLICY DENIED] Daily spending cap of $10,000.00 reached. The agent can then adapt — inform the user, try a smaller amount, or wait until the time window resets. The upstream MCP server never receives the blocked request.