Writing policies
A policy is one JSON document attached to grants on an MCP server. It decides which tools a grant can call, which tools are hidden, and which calls consume quota.
Use the dashboard’s visual policy builder first. It writes the same policy JSON shown below, but it is easier for day-to-day policy work.
Start with the visual builder
The visual builder loads the server’s tool list and gives each tool four states:
| State | Meaning |
|---|---|
| Allow | The tool can be called. |
| Deny | The tool stays visible, but every call is blocked. |
| Hide | The tool is removed from tools/list and blocked. |
| Custom | Add argument rules and limits for that tool. |
Use Custom for rules like “require args.reason on refunds”, “deny charges over 10000”, or “limit this grant to 30 calls per minute.”
Switch to Raw JSON when you want to inspect the exact document, paste in a policy, or add all_tools limits. all_tools limits are not exposed in the visual builder today.
Policy shape
{
"version": "1",
"default": "deny",
"hide": ["delete_account"],
"all_tools": {
"limits": []
},
"tools": {
"create_charge": {
"require": [],
"deny_if": [],
"limits": []
}
}
}
| Field | Required | Meaning |
|---|---|---|
version | Yes | Schema version. Only "1" is recognised. |
default | Yes | "allow" or "deny". This controls tools not listed under tools. |
hide | No | Tool names removed from streamable HTTP tools/list responses and denied at call time. The wildcard "*" hides everything. |
all_tools.limits | No | Static quota limits that apply across every tool call. |
tools[name] | No | Per-tool rules for a named MCP tool. Under default: deny, listing a tool with {} allows it. |
A grant with no policy denies every call before the document is evaluated.
Choose the default
default: deny is the safer starting point. Any tool you do not list is blocked.
{
"version": "1",
"default": "deny",
"tools": {
"list_customers": {}
}
}
default: allow is permissive. Use it when most tools should remain available and you only need to constrain specific tools.
{
"version": "1",
"default": "allow",
"tools": {
"force_push": {
"deny_if": [{ "conditions": [] }]
}
}
}
That deny_if rule has no conditions, so it always matches. The tool remains visible, but every call is denied.
Hide or deny
Use hide when the agent should not see a tool in tools/list.
Use deny_if when the agent may see the tool, but calls should fail with a policy denial.
Hidden tools are filtered from streamable HTTP tools/list responses and denied on every supported call path. A denied tool stays visible and returns a policy-denied tool result when called.
Evaluation order
For each tools/call, PolicyLayer evaluates the policy in this order. The first denial wins.
hide: hidden tool names, or"*", deny immediately.default:default: denyblocks tools not listed undertools.require: everyrequirepredicate for the tool must match.deny_if: any matchingdeny_ifpredicate denies.limits: PolicyLayer reserves quota counters before forwarding the call.
Steps 1 to 4 only inspect the call arguments. Step 5 is stateful and uses atomic quota counters.
Custom rules
Custom rules use predicates. A predicate contains conditions, and the conditions inside one predicate are ANDed together.
{
"conditions": [
{ "path": "args.amount", "op": "gt", "value": 10000 },
{ "path": "args.currency", "op": "eq", "value": "USD" }
],
"on_deny": "USD amount is above policy."
}
When a predicate denies, on_deny is the message returned to the client and recorded with the decision. If it is omitted, PolicyLayer uses a default policy-denied message.
require and deny_if use predicates differently:
| Section | Meaning |
|---|---|
require | Every predicate must match. The first unmet predicate denies. |
deny_if | Any matching predicate denies. The first matching predicate wins. |
require predicates must have at least one condition. deny_if predicates may have an empty conditions array, which means unconditional deny.
Conditions
A condition reads one value from the tool call arguments and compares it.
{ "path": "args.amount", "op": "lte", "value": 5000 }
| Field | Notes |
|---|---|
path | Must start with args.. The rest is a dotted path into the JSON arguments. |
op | One of the operators below. |
value | The literal value to compare against. |
| Operator | Meaning | Example value |
|---|---|---|
eq | Equality. Type-strict, except numbers compare across integer and float forms. | "production" |
neq | Not equal. | "prod" |
in | Argument value is in a list. | ["main", "release"] |
not_in | Argument value is not in a list. | ["main"] |
lt, lte, gt, gte | Numeric comparison. | 5000 |
regex | Go regexp matches a string argument. | "^prod-" |
contains | String contains substring, or list contains element. | "DROP" |
exists | Argument is present and non-null when value is true; absent or null when value is false. | true |
args. is the only path namespace. Dotted object paths are supported:
args.amountreads the top-levelamountfield.args.recipient.emailreads a nestedemailfield.
Array indexes are not supported in argument paths today. If you need to write policy against a value inside a list, expose that value as a named object field or top-level argument.
If a path does not resolve, the condition is unmet. The exists operator is the exception because it explicitly checks presence.
Limits
A limit reserves quota before the call is forwarded. If the increment would exceed max, the call is denied and earlier reservations from the same call are rolled back.
If the upstream response shows the tool call failed, the reservation is rolled back, so quota tracks successful tool calls.
{
"counter": "create_charge_per_minute",
"window": "minute",
"max": 30,
"scope": "grant",
"increment": 1
}
| Field | Required | Default | Meaning |
|---|---|---|---|
counter | Yes | Counter name. Distinguishes limits sharing a scope and window. | |
window | Yes | minute, hour, or day. Windows are calendar-aligned in UTC. | |
max | Yes | Maximum cumulative increments per window. Must be at least 1. | |
scope | No | grant | global, server, policy, or grant. |
increment | No | 1 | Units consumed per successful call. |
increment_from | No | Args path. When set, the counter consumes that argument value instead of increment. | |
on_deny | No | Custom denial message for this limit. |
Limit scopes
| Scope | Meaning |
|---|---|
grant | One counter per grant. Use this for per-token rate limits. |
policy | All grants attached to the same policy share the counter. |
server | All grants on the server share the counter. |
global | One counter across the whole deployment. Use sparingly. |
minute, hour, and day windows are calendar-aligned in UTC. Minute windows reset at every :00, hour windows reset at every :00:00, and day windows reset at midnight UTC.
Within one section, every (scope, counter, window) triple must be unique. A 30/minute limit and a 1000/day limit can share the same counter name because the windows differ.
Spend caps
Use increment_from when the cost of a call comes from its arguments.
{
"version": "1",
"default": "deny",
"tools": {
"create_charge": {
"limits": [
{
"counter": "daily_charge_total",
"window": "day",
"max": 50000,
"scope": "grant",
"increment_from": "args.amount",
"on_deny": "Daily charge limit exceeded."
}
]
}
}
}
A create_charge call with amount: 12000 consumes 12000 from the daily 50000 cap.
increment_from must resolve at runtime to an integer of at least 1. Missing, fractional, non-numeric, and negative values fail policy evaluation. Use smaller units, such as cents instead of dollars, if you need fractional precision.
all_tools.limits cannot use increment_from because argument fields are tool-specific. Use static increment for all_tools limits.
Cross-tool limits
all_tools carries limits only. It does not support cross-tool require or deny_if rules.
That is deliberate: condition paths only read from args.<path>, and argument names are tool-specific. A cross-tool requirement on args.amount would deny every call to tools that do not have an amount argument.
If you need the same predicate on multiple tools, repeat it under each tool. The visual builder covers per-tool rules. Edit all_tools limits in Raw JSON.
Saving policies
Schema validation runs on save. The validator rejects unknown operators, malformed regexes, invalid limits, duplicate hide entries, empty defaults, and any version other than "1".
Body edits create a new immutable policy version. Renaming a policy is metadata-only and does not create a new version. After a policy changes, the proxy invalidates its policy cache; connected clients do not need to reconnect.