mcp-permission-server

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.

Category
访问服务器

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.
  • 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.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选