mcp-permission-server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-permission-serverdeny fs.read on /data/reports-private and log the denial"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-permission-server
A permission layer in front of MCP tools, and a log that can prove what it decided.
One of five. This repo is the permission grant + audit log layer. If your agent needs more than one of them, see Part of a set at the bottom.
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 0NAIVE 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 alonechain: OK - the chain is intact
replay: 17/17 entries recompute to the verdict they record
secrets in the log: noneExit code: 0 all allowed, 1 something denied, 2 the log does not verify.
What this layer looks like with each check switched off. GuardStack (bottom of this file) merges these five repos onto one hash-chained log, and its benchmark publishes the ablation as a file: evidence report. It removes
grant,scopeandexpiryseparately — each drops 6/6 stopped to 5/6, so each is catching one case no other check catches; all three off is 3/6. Measured on shipped fixtures, and labelled as such.
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 |
| a different directory that starts with the same characters | tool ✓ purpose ✓ expiry ✓ |
| the same directory, reached through the granted one | tool ✓ purpose ✓ expiry ✓ |
| the grant ran out forty ticks ago. Nothing about the call is wrong | tool ✓ scope ✓ purpose ✓ |
the third | a grant is a budget as well as a permission | tool ✓ scope ✓ purpose ✓ |
| identical to the first call in the session except for why it is being made | tool ✓ scope ✓ expiry ✓ |
| the template pulls in | tool ✓ scope ✓ purpose ✓ expiry ✓ |
| 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.
Related MCP server: mcp-policy-gateway
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 nothingThe 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 recordWith 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 editedSecrets 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 unnoticedpython3 ablate.pyOne 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/ 42 testsRun 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 # 42 testsDocker, with the network switched off:
docker compose run --rm gateTests
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.
Part of a set
This repo is one of five agent-safety layers I maintain. All MIT, all free, and staying that way.
Layer | Repo |
Input / output guard | |
Permission grants + audit log |
|
Approval gate | |
Retry / timeout / circuit breaker | |
Read-only enforcement |
They were built as five separate demos, so they do not compose: each carries its own config, its own audit log, and its own idea of what "denied" means. Wiring them into one agent is a real job.
GuardStack is that job, already done — five gates in a fixed order, one call each, writing to one shared hash-chained audit log, so "what did the agent try, and under which rule was it allowed" is answerable from a single file. It ships with framework adapters (OpenAI-compatible wrapper, FastAPI middleware), a test suite written against the composition rather than the five demos, and a benchmark you re-run on your own corpus, with an ablation that prices gates 1-5 (input, permission, approval, reliability, output) and labels which numbers came from your traffic and which from the shipped fixtures. The current test count and every other figure live in the evidence report, which is regenerated from the benchmark — this file deliberately does not repeat them, because a version-dependent number copied into five repos is a number that goes stale in five places at once. It did, twice.
It also ships docs/LIMITS.md, which is the part worth reading first: the input guard
stops 27/27 of our corpus and 5/42 of a corpus written to break it — and that
second corpus ships in the box, so the bad number is one command away rather than a
sentence you have to take on trust. Budgets are per-process, and the audit log assumes
a single writer. Gates 2, 3 and 5 are where it earns its keep. You should know that
before you pay, not after.
$49. Assembling the five yourself is a legitimate choice, and the repos above are the right place to start — this is the two weeks of wiring you skip.
This server cannot be deployed
Maintenance
Related MCP Connectors
- gatewayOAuthai.sealgate
MCP gateway with runtime security policy, tool-call-level control, and audit of agent actions.
Identity, authorization, audit trails, and revocable permissions for AI agents accessing MCP tools.
Remote MCP for A2A caller identity, scope policy, verdict receipts, and audit history.
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
Related MCP Servers
AlicenseNot gradedqualityCmaintenancePolicy enforcement gateway for MCP tool calls, evaluating every tool invocation against declarative YAML policies (allow/deny/escalate-to-human), generating cryptographic hash-chained audit receipts, and including built-in content safety scanning.2MIT- AlicenseNot gradedqualityAmaintenanceAn authorizing reverse proxy for MCP servers that enforces per-call policy rules on tool arguments with audit logging, dry-run, and rate limiting.Apache 2.0
- AlicenseNot gradedqualityBmaintenanceEnforces identity-based access control and audit logging for MCP servers, letting you grant fine-grained tool permissions to users and systems while failing secure by default.MIT
- AlicenseNot gradedqualityCmaintenanceEnforces deterministic security policies as an inline firewall for MCP server tool calls, with AST-based validation, cryptographic audit logging, and CLI-based evaluation and verification.MIT