Feature: support argument-level patterns in MCP tool permission rules (parity with Bash rules)
Summary
Bash permission rules support argument-level glob patterns:
"Bash(gh api:*)"
"Bash(uv run pytest:*)"
MCP tool permission rules support only tool-level matching — no argument patterns:
"mcp__playwright__browser_navigate" // allowed
"mcp__playwright__browser_navigate(http://localhost:*)" // ❌ rejected by schema validation
Desired behaviour
Allow MCP rules to accept the same parenthesised argument pattern syntax that Bash rules use, so users can express fine-grained restrictions like:
"mcp__playwright__browser_navigate(http://localhost:*)"
"mcp__playwright__browser_tabs(action: select*)"
This would let project settings.json / settings.local.json restrict an MCP tool both by name (which tool may be called) and by argument (what values it may be called with), without requiring a separate PreToolUse hook.
Current workaround
The only way to enforce argument-level restrictions today is a PreToolUse hook. Getting it right has two non-obvious pitfalls:
- Exit code must be 0, not 1 or 2 — the block decision is communicated via JSON on stdout using
hookSpecificOutput.permissionDecision: "deny". Exit code 2 also blocks but swallows the reason message. - The tool input is nested under
tool_inputin the stdin JSON, not at the top level.
Working example that restricts browser_navigate to localhost and browser_tabs to safe actions only:
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__playwright__browser_navigate",
"hooks": [{
"type": "command",
"command": "python3 -c \"import sys,json; d=json.load(sys.stdin); u=d.get('tool_input',d).get('url',''); (print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse','permissionDecision':'deny','permissionDecisionReason':'browser_navigate is restricted to localhost URLs. Attempted: '+u}})) or None) if 'localhost' not in u else None; sys.exit(0)\""
}]
},
{
"matcher": "mcp__playwright__browser_tabs",
"hooks": [{
"type": "command",
"command": "python3 -c \"import sys,json; d=json.load(sys.stdin); a=d.get('tool_input',d).get('action',''); (print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse','permissionDecision':'deny','permissionDecisionReason':'browser_tabs action not permitted: '+a+'. Allowed: select, list, new'}})) or None) if a not in ('select','list','new') else None; sys.exit(0)\""
}]
}
]
}
This works and produces a clear denial message (e.g. "browser_navigate is restricted to localhost URLs. Attempted: https://example.com"), but it is verbose, fragile (inline Python in JSON-escaped strings), and requires knowing the undocumented tool_input nesting and the hookSpecificOutput JSON contract.
The equivalent intent expressed as a permission rule would be two readable lines:
"mcp__playwright__browser_navigate(http://localhost:*)",
"mcp__playwright__browser_tabs(action: select*, list*, new*)"
Use cases
Argument-level restrictions are useful any time you want to auto-approve a tool but constrain what values it can be called with:
| MCP server | Tool | Desired restriction |
|------------|------|---------------------|
| Playwright | browser_navigate | Localhost URLs only during local E2E testing |
| Playwright | browser_tabs | Safe actions only (select, list, new) — not close |
| Filesystem | write_file | Paths within the project directory only |
| Database | execute_query | SELECT statements only (no INSERT/UPDATE/DELETE) |
| GitHub | create_issue / push | Specific repos only |
| Email / Slack | send_message | Internal domain recipients only |
In every case the tool itself is trusted — only certain argument values should be auto-approved without prompting the user.
Proposed syntax
Follow the existing Bash(pattern) convention exactly:
| Rule | Meaning |
|------|---------|
| mcp__playwright__browser_navigate | Allow any URL (current behaviour) |
| mcp__playwright__browser_navigate(http://localhost:*) | Allow localhost URLs only |
| mcp__playwright__browser_tabs(action: select*) | Allow select/new/list only |
| mcp__playwright__* | Allow all tools from server (current behaviour) |
Discovered while setting up Playwright MCP for Flutter web E2E testing.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗