mcp-permission-server
Enforces fine-grained, context-aware access control on MCP tool calls, with a tamper-evident, replayable audit log that records denials and verifies every decision.
README
mcp-permission-server
A permission layer in front of MCP tools, and a log that can prove what it decided.
Every audit trail records what happened. Almost none record enough to recompute why it was allowed, and most record only what succeeded — so when somebody asks "how did the agent reach that file", the log cannot answer. It can only repeat the assertion that a decision was made.
NAIVE GUARDED
calls in the session 17 17
calls that must be denied 8 8
EXECUTED WITHOUT A GRANT 7 0
LEGITIMATE CALLS REFUSED 0 0 ← no over-blocking
DECISIONS THAT CANNOT BE 16 0 ← the number nobody reports
RECONSTRUCTED FROM THE LOG
denials that left no trace 1 0
secrets sitting in the log 2 0
entry removable unnoticed 1 0
NAIVE is the permission model an MCP server ships with when permissions were
not the point: a list of tool names that are allowed, and a log of what
succeeded. Every call in this session except one passes it.
python3 demo.py
python3 gate.py --log audit.json # run the session
python3 gate.py --verify audit.json # re-decide every entry from the log alone
chain: OK - the chain is intact
replay: 17/17 entries recompute to the verdict they record
secrets in the log: none
Exit code: 0 all allowed, 1 something denied, 2 the log does not verify.
A grant is four things at once
Which is four separate ways for a live grant to still not cover a call — and the reason "does this caller have permission for this tool" is the wrong question to ask.
{"id": "G1", "tool": "fs.read", "scope": "/data/reports",
"purpose": "q3-report", "expires_at": 100, "max_uses": 10}
| the call | what is wrong with it | what else would have allowed it |
|---|---|---|
fs.read /data/reports-private/salaries.csv |
a different directory that starts with the same characters | tool ✓ purpose ✓ expiry ✓ |
fs.read /data/reports/../reports-private/salaries.csv |
the same directory, reached through the granted one | tool ✓ purpose ✓ expiry ✓ |
fs.read /data/reports/q4.csv at t=140 |
the grant ran out forty ticks ago. Nothing about the call is wrong | tool ✓ scope ✓ purpose ✓ |
the third fs.write against a grant good for two |
a grant is a budget as well as a permission | tool ✓ scope ✓ purpose ✓ |
fs.read /data/reports/q3.csv for marketing-export |
identical to the first call in the session except for why it is being made | tool ✓ scope ✓ expiry ✓ |
render_template /templates/leak.md |
the template pulls in /etc/service-token, which the caller has no grant for and never named |
tool ✓ scope ✓ purpose ✓ expiry ✓ |
secrets.get DB_ADMIN_PASSWORD |
holding a grant for one key is not holding the keyring | tool ✓ purpose ✓ expiry ✓ |
There is a test that, for each of these, turns off only the check it names and requires the call to get through. A denial that several checks catch is a denial that proves nothing about any of them.
The confused deputy
render_template is granted. /templates/leak.md is inside the granted scope.
The template contains {{include:/etc/service-token}}.
The server holds rights the caller does not, and is being asked to spend them on a resource the caller never named. A permission layer that checks the resource in the arguments is checking the wrong thing for exactly the tools where it matters.
So the check looks at what a call reaches, before it reaches it — and then
still says yes to /templates/report.md, which reaches
/data/reports/q3.csv, which this caller does hold a grant for. Half of a
permission check is the yes. A rule that blocks every template because
templates can include things is not a rule.
Path containment, which is where this is usually got wrong
Both mistakes are one line long.
path.startswith(scope) # /data/reports-private is "inside" /data/reports
".." in path # and this breaks ordinary callers to catch nothing
The second one deserves its own case. /data/reports/../reports/q3.csv
resolves back inside the granted scope, and it is in the corpus as a call
that must be allowed. Banning the characters is the cheap fix; resolving
the path catches everything banning it would, and nothing it would not.
Resolution here is lexical and never touches a filesystem — there is a test
that greps for realpath, os.stat and open(. The decision runs before the
call, on a path the caller supplied, and it must not be answerable differently
depending on what happens to exist.
The log is the product
Two properties make it evidence rather than a diary.
It records denials. A log of what succeeded cannot answer "what did the agent try", which is the question anybody actually asks afterwards. Here, eight of the seventeen entries are refusals, each with the check that made it and the reason in plain English.
It can be replayed. Every entry carries the grant it was decided under and the policy version, so the decision can be recomputed from the log alone and compared to the verdict it records:
python3 gate.py --verify audit.json
replay: 17/17 entries recompute to the verdict they record
With the replay field off, that number is 0/17 — the entries still say
what was decided and give nobody any way to check it.
And the entries are hash-chained, so a removed or edited one is visible:
entry 6 does not follow the one before it -- something was removed or edited
Secrets never enter it. An audit trail holding the secret is a second copy
of the secret, in a file more people can read. secrets.get is allowed in this
session and its value appears nowhere in the log; the naive log contains two.
The nine checks, and the proof that each earns its place
check what turning it off lets through
grant 1 more unauthorised, 1 more unreconstructable, 1 more egress
scope 3 more unauthorised, 3 more unreconstructable
expiry 2 more unauthorised, 2 more unreconstructable
purpose 1 more unauthorised, 1 more unreconstructable
deputy 1 more unauthorised, 1 more unreconstructable
redact 1 more secret in the log
deny_log 8 more denials that left no trace
replay 17 more decisions that cannot be reconstructed
chain 1 more entry removable unnoticed
python3 ablate.py
One correction worth recording: the first version of the scope ablation did
not turn the check off — it substituted the string-prefix comparison, which is
a different check rather than the absence of one, and it quietly kept
denying a call that existed to prove the check was needed. Turning a check
off has to mean turning it off, or the ablation is measuring a swap.
The same session also exposed a grant sized so that its last call was denied for being one use too many rather than for naming a key it did not cover. It was testing the wrong check and passing. The fix was in the fixture, and the test that found it is the one that requires each denial to isolate its own check.
Known limits, stated rather than hidden
- This is the decision layer, not a transport. It decides, executes against an in-process tool table, and logs. Wiring it to a real MCP transport is ordinary work; what is here is the part that is usually missing, and it runs with no network and no key.
- Grants are written down, not elicited. A real deployment asks a person and stores the answer. What that answer has to contain — tool, scope, purpose, expiry, use count — is the part this argues about.
purposeis declared by the caller and taken at face value. A caller that lies about its purpose defeats the check. It still buys something real: the declared purpose is in the log, so the lie is on the record and the grant's scope did not widen to accommodate it.- The deputy check knows one tool's reach.
render_templateparses its includes. A tool whose reach the server cannot compute cannot be checked this way, and the honest answer for such a tool is a narrower grant rather than a wider check. - The chain proves ordering and integrity, not custody. Anyone who can rewrite the whole file can rewrite the whole chain. It makes a selective edit visible, which is the realistic threat; an append-only store or an external anchor is what makes the rest visible, and that is deployment work.
Layout
fixtures/spec.py the session, the grants, the world, and the answer key
build_fixtures.py renders session.json, policy.json, world.json, truth.json
mcpgate/scope.py is this path inside that scope
mcpgate/policy.py a grant is four things at once
mcpgate/tools.py the tool table, and what a call actually reaches
mcpgate/audit.py the hash-chained, redacted, replayable log
mcpgate/server.py naive() and guarded(), and replay()
gate.py the CLI: run a session, or verify a log
score.py every number above
ablate.py each check off, one at a time
tests/ 38 tests
Run it
python3 demo.py # both servers + ablation
python3 gate.py --log audit.json # the product
python3 gate.py --verify audit.json # the proof
python3 -m pytest tests -q # 38 tests
Docker, with the network switched off:
docker compose run --rm gate
Tests
One asserts that the allowlist still lets almost everything through. One replays every logged decision and requires the same verdict. One requires each denial to survive only because of the check it names, so the ablation numbers mean what they say. One greps the path resolver for filesystem calls.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.