Skip to main content
Glama
jigonyoo

mcp-permission-server

by jigonyoo

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.


Related MCP server: MCP Airlock

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.

  • purpose is 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_template parses 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.

A
license - permissive license
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Policy 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.
    2
    MIT
  • A
    license
    C
    quality
    D
    maintenance
    Enables secure, zero-trust access to MCP tools through short-lived, signed capability leases that bind tool execution to specific sessions, intents, and constraints. Prevents prompt injection attacks and privilege escalation with dynamic risk scoring, policy enforcement, and tamper-evident audit logging.
    4
    1
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    An 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
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enforces 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

View all related MCP servers

Related MCP Connectors

  • Remote MCP for A2A caller identity, scope policy, verdict receipts, and audit history.

  • Runtime permission, approval, and audit layer for AI agent tool execution.

  • Remote MCP for Copilot CLI switch gate MCP, structured receipts, audit logs, and reviewer-ready evid

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jigonyoo/mcp-permission-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server