Authorization Enrichment: Argument-Aware MCP Policy
Out of the box, policy sees an MCP tool call as filesystem/read_file. It can allow or deny the tool, but it can't tell a read of a public file from a read of a private one, because the file is an argument. In this module you write a small enrichment policy that turns the tool call and its path into a resource-scoped identifier, then write a policy that allows one directory and denies the other.
What You'll Learn
- Writing a Rego enrichment policy that maps a tool call and its arguments to an operation and a resource MRN
- Attaching the policy to an MCP server with an
enrichment:block - Allowing a public scope and denying a private scope in the PolicyDomain
- Reading the before and after operation and resource, and
context.mcp.original_*, in the decision logs - Making the policy hold against
..path tricks
Prerequisites
This module continues in the project you finished in the Basic tutorial. If you skipped it, scaffold the same agent:
agentvisor template create langgraph/tutorial-agent
cd tutorial-agent
Docker must be running, and Temporal and Ollama must be up. In the project directory:
docker compose up -d
No API key is needed. The filesystem MCP server from Connecting External Tools is the tool you'll govern, because its tools take a path argument that names the resource.
How It Works
Before enrichment
operation: agentvisor:mcp:tool:call
resource: mrn:agentvisor:mcp:filesystem/read_file
After enrichment
operation: mcp:fs:read
resource: mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt
On the host, before the policy engine decides, the enrichment policy receives the tool name and its arguments and returns a new operation and resource. The policy engine then evaluates the enriched values with ordinary selectors, with no knowledge of how any tool's arguments are shaped. The originals are kept in the policy context so you can still see them.
Step 1: Give the Server Some Files to Protect
The filesystem server runs inside AgentVisor's mcp-tools sandbox, which has its own empty /tmp: files you create on your machine aren't visible to it. Instead, have the server create its sample data when it starts. In agentvisor.yaml, replace the command of the filesystem server:
mcp:
servers:
- name: filesystem
transport: stdio
command:
- sh
- -c
- |
mkdir -p /tmp/mcp-data/public /tmp/mcp-data/private
echo 'Hello from MCP!' > /tmp/mcp-data/public/readme.txt
echo 'alice: 180000' > /tmp/mcp-data/private/salaries.txt
exec npx -y @modelcontextprotocol/server-filesystem /tmp/mcp-data
The server is the same, and it still serves /tmp/mcp-data. It now has a public directory and a private one.
Let a Message Read a File Directly
As with fetch <url>, let a message of the form read <path> call the tool directly, so the results don't depend on a small local model choosing to call it. In agent.py, add this pattern beside FETCH_COMMAND:
READ_COMMAND = re.compile(r"^\s*read\s+(/\S+)\s*$", re.IGNORECASE)
and this block in chat, just before the line that builds conversation:
# "read <path>" from the user calls the MCP read_file tool directly.
if messages and isinstance(messages[-1], HumanMessage):
match = READ_COMMAND.match(str(messages[-1].content))
if match:
call = {"name": "filesystem__read_file", "args": {"path": match.group(1)}, "id": f"call_{uuid.uuid4().hex[:12]}"}
return {"messages": [AIMessage(content="", tool_calls=[call])]}
Step 2: Write the Enrichment Policy
Create enrichment/filesystem.rego:
package agentvisor.authz.enrichment
import rego.v1
# Tools that take a `path` argument, grouped by what they do to it.
read_tools := {"read_file", "read_text_file", "read_media_file", "list_directory", "directory_tree", "search_files", "get_file_info"}
write_tools := {"write_file", "edit_file", "create_directory", "move_file"}
# Everything under this directory is public; everything else is private.
public_root := "/tmp/mcp-data/public"
# A path is public only if it is inside the public root and has no ".."
# segment that could climb back out of it.
is_public(p) if {
p == public_root
}
is_public(p) if {
startswith(p, concat("", [public_root, "/"]))
not regex.match(`(^|/)\.\.(/|$)`, p)
}
scope(p) := "public" if is_public(p)
scope(p) := "private" if not is_public(p)
result := {"operation": op, "resource": res} if {
read_tools[input.tool_name]
p := input.arguments.path
op := "mcp:fs:read"
res := sprintf("mrn:agentvisor:fs:%s:%s", [scope(p), p])
}
result := {"operation": op, "resource": res} if {
write_tools[input.tool_name]
p := input.arguments.path
op := "mcp:fs:write"
res := sprintf("mrn:agentvisor:fs:%s:%s", [scope(p), p])
}
A few things to notice:
- The package name and the
resultrule are fixed. The host evaluatesdata.agentvisor.authz.enrichment.result, andresultmay carryoperation,resourceandcontext. - The policy reads
input.tool_nameandinput.arguments, which describe the call. See Input Signals for the full list. - The two
resultrules have mutually exclusive conditions: a tool is either inread_toolsor inwrite_tools. If two rules could both fire with different values, Rego reports a conflict and enrichment is skipped. - Anything that isn't plainly inside the public root is classed
private, including a path with a..segment. The policy sorts a path into a scope. It doesn't try to prove a path is dangerous. - A tool with no rule, such as
list_allowed_directories, produces no result, and its original operation and resource are used unchanged.
You can try the policy without running AgentVisor. If you have the opa CLI:
echo '{"tool_name":"read_file","arguments":{"path":"/tmp/mcp-data/private/salaries.txt"}}' > input.json
opa eval -d enrichment/filesystem.rego -i input.json 'data.agentvisor.authz.enrichment.result' -f raw
{"operation":"mcp:fs:read","resource":"mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"}
Step 3: Attach the Policy to the Server
Add an enrichment: block to the same server in agentvisor.yaml:
mcp:
servers:
- name: filesystem
transport: stdio
command:
# ... unchanged from Step 1 ...
enrichment:
provider: rego
rego_policy: ./enrichment/filesystem.rego
rego_policy is a path on the host, resolved from the directory you run agentvisor serve in. Enrichment is configured per server, so other MCP servers are unaffected.
Step 4: Update the PolicyDomain
The enriched resources, mrn:agentvisor:fs:public:... and mrn:agentvisor:fs:private:..., don't match any selector in policies/domain.yml yet. Make three changes.
Add a resource group for the public scope, above the default group under resource-groups::
- mrn: "mrn:agentvisor:resourcegroup:fs-public"
name: fs-public
description: "Files in the public directory"
policy: *policy-allow
Route the two scopes under resources:. The private scope goes to the default group, which denies:
- name: fs-public
description: "Files the enrichment policy placed in the public scope"
selector:
- "mrn:agentvisor:fs:public:.*"
group: "mrn:agentvisor:resourcegroup:fs-public"
- name: fs-private
description: "Files the enrichment policy placed in the private scope"
selector:
- "mrn:agentvisor:fs:private:.*"
group: "mrn:agentvisor:resourcegroup:default"
Finally, the enriched operations mcp:fs:read and mcp:fs:write are new names, so route them to the operation policy as well. Under operations:, add a selector beside agentvisor:.*:
operations:
- name: agentvisor-ops
selector:
- "agentvisor:.*"
- "mcp:fs:.*"
policy: *policy-operation
Step 5: Run It and Watch the Decisions
Start the agent with pretty-printed access logs, so each decision prints in full:
agentvisor serve . --access-log-pretty-print
In a second terminal, read a file from each directory:
THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')
ask() {
curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d "{\"input\": {\"messages\": [{\"role\": \"user\", \"content\": \"$1\"}]}}" \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content'
}
ask "read /tmp/mcp-data/public/readme.txt"
ask "read /tmp/mcp-data/private/salaries.txt"
The first returns the file. The second is refused:
Hello from MCP!
Tool call denied: denied by policy
Same tool, same server, same principal: the only difference is the argument.
Read the Decision Log
Look at the host's output for the denied call. Two log lines already show the enriched values:
INFO [host] MPE authorization check {"principal": "anonymous", "operation": "mcp:fs:read", "resource": "mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"}
...
INFO [host] MPE authorization result {"allowed": false, "principal": "anonymous", "operation": "mcp:fs:read", "resource": "mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"}
Between them, the access log prints the full decision. These are the parts that matter, trimmed:
{
"decision": "DENY",
"operation": "mcp:fs:read",
"porc": {
"context": {
"mcp": {
"arguments": { "path": "/tmp/mcp-data/private/salaries.txt" },
"original_operation": "agentvisor:mcp:tool:call",
"original_resource": "mrn:agentvisor:mcp:filesystem/read_file",
"server": "filesystem",
"tool": "read_file"
}
},
"operation": "mcp:fs:read",
"resource": {
"group": "mrn:agentvisor:resourcegroup:default",
"id": "mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"
}
},
"references": [
{ "decision": "GRANT", "phase": "SYSTEM", "id": "mcp:fs:read" },
{ "decision": "GRANT", "phase": "IDENTITY", "id": "mrn:agentvisor:role:anonymous" },
{ "decision": "DENY", "phase": "RESOURCE", "id": "mrn:agentvisor:resourcegroup:default" }
]
}
Here is the before and after:
| Before enrichment | After enrichment | |
|---|---|---|
| Operation | agentvisor:mcp:tool:call (context.mcp.original_operation) | mcp:fs:read (operation) |
| Resource | mrn:agentvisor:mcp:filesystem/read_file (context.mcp.original_resource) | mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt (resource.id) |
The operation and identity phases granted, and the resource phase denied because the resource landed in the default group. Find the matching entry for readme.txt too: the same log shows fs:public: routed to the fs-public group and a GRANT in the resource phase. Policy decides on the enriched values. The originals are there for policies and audits that still want them.
Try to Get Around It
A path that starts in the public directory but climbs out of it would pass a naive prefix check:
ask "read /tmp/mcp-data/public/../private/salaries.txt"
Tool call denied: denied by policy
The decision log shows why: the resource is mrn:agentvisor:fs:private:/tmp/mcp-data/public/../private/salaries.txt. The .. segment made the policy class it as private, so the deny rule applied. Decide on a scope the policy computed, not on the raw string the agent sent.
Enrichment Is Fail-Open
Enrichment can never block a request on its own. If no rule matches, or the policy errors, the original operation and resource are used. Here that means a failed enrichment falls back to mrn:agentvisor:mcp:filesystem/read_file, which this domain's existing mrn:agentvisor:mcp:.* selector allows. In a real deployment, pair enrichment with a coarse rule that stays safe when enrichment is skipped, such as restricting which tools the original MRN may reach. See Fail-Open Behavior.
Troubleshooting
| Symptom | Likely cause |
|---|---|
The decision log shows agentvisor:mcp:tool:call and the original MRN | Enrichment produced no result. Check rego_policy's path, the package name, and that the tool name is in read_tools or write_tools. |
| Every read is denied, including the public file | The enriched operation or resource doesn't match a selector. Compare the operation and resource in the log with Step 4. |
| The MCP server fails at startup | The server's data directory doesn't exist inside the sandbox. Keep the mkdir in Step 1. |
Next Steps
You've written a policy that decides on what a tool call touches, not only which tool it calls. The rego provider ran in-process and needed no external service. When the decision logic lives in a system you already operate, such as a CMDB or ticketing system, use the HTTP enrichment provider instead: AgentVisor sends the same signals to your service as JSON and uses its response, with the same fail-open behavior.
- Authorization Enrichment: the full input, result and provider reference, including the HTTP provider and its wire protocol
- Gateways: how the MCP and A2A gateways call policy
- MCP Gateway Guide: server configuration, sandboxing and credentials
- Configuration Reference: Authorization Enrichment: every
enrichment:field - Policy Configuration: writing and testing PolicyDomains