Why policy is first-match-wins
mcpgw evaluates policy.rules[] top-down and stops at the first match. This is the same model as iptables, NGINX location blocks, and most real-world ACL systems. It is not the same model as RBAC, where rules combine declaratively. This document explains why mcpgw chose this and what it implies for how you write policy.
The two models
The classic split:
Procedural (first-match-wins): “Walk the list. The first rule that matches wins. Subsequent rules are not evaluated.” Order is meaningful. Conflicts are resolved by position.
Declarative (least-privilege merge): “Aggregate all matching rules. Resolve conflicts by precedence (deny-overrides-allow, etc.).” Order is irrelevant. Conflicts are resolved by static rule.
Procedural is harder to analyze in the abstract — you need a simulator to know what a given input does. Declarative is easier to analyze but harder to write — you need to predict the outcome of all rule combinations, including ones you didn’t think of.
mcpgw chose procedural because:
- The simulator is one line of jq.
jq -c 'select(.tool == "fs_write") | select(...)' rules.yamltells you which rule fires for a given tool name. There is no intermediate semantic layer to understand. - MCP traffic is high-cardinality on tool name, not on identity. Most policy decisions are about the tool, not about the who. RBAC’s strength (composable identity-driven permissions) doesn’t pay off when the dominant axis is tool-name.
- Operators already know this model. If you’ve ever written an
iptableschain or an NGINXlocationblock, you can write mcpgw policy.
What you have to internalize
Rule order matters
The most common mistake: putting a wildcard rule above a specific rule.
# WRONG — the wildcard captures fs_write before rl-fs-write can fire
- { id: redact-secrets, action: redact, when: { tool_name: "*" }, ... }
- { id: rl-fs-write, action: rate_limit, when: { tool_name: "fs_write" }, ... }
The fix is mechanical: put specific rules first, wildcard rules last.
# CORRECT
- { id: deny-shell, action: deny, when: { tool_name: "shell_exec" } }
- { id: rl-fs-write, action: rate_limit, when: { tool_name: "fs_write" }, ... }
- { id: redact-secrets, action: redact, when: { tool_name: "*" }, ... }
A useful mental model: read the rules top-to-bottom and ask “if a request matched only this rule, what would happen?” The first rule whose effect you want for that request is where it should go.
Two safe defaults: allow vs deny
If no rule matches, policy.default_action decides the request. The default is allow, which preserves v1 behavior and keeps new upstream tools usable until an operator writes policy for them.
default_action: deny flips the model to an explicit allowlist. This is the right posture for regulated environments where “only enumerated tools may run” matters more than discovery convenience.
The trade-off is operational: new MCP tools will be blocked until an allow rule is added. That is intentional. Audit lines for this terminal decision use decision: "deny" and rule_id: "default_deny", so operators can query exactly what the allowlist is missing.
policy:
default_action: deny
rules:
- id: allow-readonly-git
action: allow
when: { tool_name_in: [git_log, git_diff, git_show] }
A rule is a single decision, not a pipeline
A redact rule fires, the body is rewritten, and the request is forwarded. That’s it. There is no “redact then also rate-limit” because the redact rule already won the match.
If you need both redaction and rate-limiting on the same tool, you have to think about which one is the primary decision. Usually rate-limit is primary (the rate-limit rule both throttles AND implicitly redacts via a downstream redact rule that scopes to a different tool). Or, if redaction is universal and rate-limiting is per-tool:
# Rate-limit fires first for fs_write specifically
- { id: rl-fs-write, action: rate_limit, when: { tool_name: "fs_write" }, ... }
# Redact fires for everything else (including allowed fs_write calls? No — see below)
- { id: redact-secrets, action: redact, when: { tool_name: "*" }, ... }
Wait — if rate-limit allows the call (within the bucket), it does not also fire redact. The rate-limit rule is the match; redact never gets a turn. This is the most common gotcha.
The fix in v1 is to duplicate the redact regex into the rate-limit rule’s redact[] block (no — rate_limit rules do not have a redact[] field in v1) or to accept that fs_write within burst is not redacted (often acceptable since fs_write payloads are not typically secret-bearing).
A future release will add chain: true on individual rules to enable rule-pipeline semantics where a rule can match-and-continue rather than match-and-terminate. This is a known limitation today.
What this is not
Three things mcpgw policy is not, despite occasionally looking like them:
- Not RBAC. mcpgw authenticates requests to API keys (
auth.keys[].id), not to users or roles. Sessions are opaque ids. Mapping users to keys/sessions is your auth-proxy’s job. - Not a WAF. mcpgw inspects JSON-RPC structure, not arbitrary HTTP semantics. It will not catch SQL injection in tool arguments unless you write a redact regex for it (and even then, regex is not a parser).
- Not a runtime sandbox. mcpgw can refuse to forward a tool call. It cannot stop the tool from doing something destructive once the upstream receives it. The upstream’s own permissions and the runtime environment do that work.
mcpgw’s superpower is cheap, observable, hot-reloadable policy at the MCP-protocol layer. Push deeper concerns (identity, sandboxing, network egress) to the layer that already owns them.