← All Docs

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:

StateMeaning
AllowThe tool can be called.
DenyThe tool stays visible, but every call is blocked.
HideThe tool is removed from tools/list and blocked.
CustomAdd 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": []
    }
  }
}
FieldRequiredMeaning
versionYesSchema version. Only "1" is recognised.
defaultYes"allow" or "deny". This controls tools not listed under tools.
hideNoTool names removed from streamable HTTP tools/list responses and denied at call time. The wildcard "*" hides everything.
all_tools.limitsNoStatic quota limits that apply across every tool call.
tools[name]NoPer-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.

  1. hide: hidden tool names, or "*", deny immediately.
  2. default: default: deny blocks tools not listed under tools.
  3. require: every require predicate for the tool must match.
  4. deny_if: any matching deny_if predicate denies.
  5. 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:

SectionMeaning
requireEvery predicate must match. The first unmet predicate denies.
deny_ifAny 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 }
FieldNotes
pathMust start with args.. The rest is a dotted path into the JSON arguments.
opOne of the operators below.
valueThe literal value to compare against.
OperatorMeaningExample value
eqEquality. Type-strict, except numbers compare across integer and float forms."production"
neqNot equal."prod"
inArgument value is in a list.["main", "release"]
not_inArgument value is not in a list.["main"]
lt, lte, gt, gteNumeric comparison.5000
regexGo regexp matches a string argument."^prod-"
containsString contains substring, or list contains element."DROP"
existsArgument 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.amount reads the top-level amount field.
  • args.recipient.email reads a nested email field.

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
}
FieldRequiredDefaultMeaning
counterYesCounter name. Distinguishes limits sharing a scope and window.
windowYesminute, hour, or day. Windows are calendar-aligned in UTC.
maxYesMaximum cumulative increments per window. Must be at least 1.
scopeNograntglobal, server, policy, or grant.
incrementNo1Units consumed per successful call.
increment_fromNoArgs path. When set, the counter consumes that argument value instead of increment.
on_denyNoCustom denial message for this limit.

Limit scopes

ScopeMeaning
grantOne counter per grant. Use this for per-token rate limits.
policyAll grants attached to the same policy share the counter.
serverAll grants on the server share the counter.
globalOne 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.

// GET IN TOUCH

Have a question or want to learn more? Send us a message.

Message sent.

We'll get back to you soon.

// REQUEST EARLY ACCESS

We're letting people in as fast as we can.

You're in the queue.

We'll be in touch as soon as we can let you in.