Prove that your MCP server denies what it must deny.
One command · every identity · real calls · twelve rules · zero dependencies · MIT
Install · Run it · The rules · Policy · Evidence · CI · Specification · Website · Русский
Real output from the bundled vulnerable server. Exit code 1 means a blocking finding. Examples · Why this exists.
mcp-attest tests a live MCP server against the access control you wrote. It connects as each identity in your policy, sends real calls, and reports every place where the server's decision differs from the decision your policy requires.
It is not a gateway. It cannot block anything. It proves what your server does.
Why mcp-attest?
- It tests the decision, not the definition. A scanner tells you what a tool description looks like.
mcp-attesttells you whetheralicemay callrefund_order— by calling it, asalice, and looking at what came back. - The matrix is generated, not typed. Write who may call what. The harness derives every identity × tool pair, derives the arguments from the schema, and compares. Adding a tool to your server does not mean rewriting the test suite.
- Deterministic. No model, no API key, no network beyond the server under test. The same policy and the same server give the same report every time, which is what makes it usable as a gate.
- Evidence, not opinion. Every call is recorded with the request, the response, the identity, the timestamp and the policy decision. The records are hash-chained and optionally HMAC-signed, so an auditor can verify the bundle offline without trusting the machine that produced it.
- Exit codes, not vibes.
0clean,1blocking finding,2bad input. It is a CI gate without a wrapper script. - Zero dependencies. Python 3.10+ and the standard library. It speaks JSON-RPC itself instead of pulling an SDK into your pipeline.
Where this sits
The MCP testing space is crowded, and pretending otherwise would be dishonest. mcp-assert lints, tests and fuzzes servers over real transports with YAML assertions; mcp-fence scans configuration, inspects and fuzzes with a Docker sandbox; mcp-fuzz grades protocol robustness; MCP-Scan scans tool metadata for poisoning; promptfoo red-teams agents and MCP servers with a model in the loop; mcpsnoop and mcp-conformance judge recorded sessions.
What none of them does is the thing the OWASP MCP Security Testing Guide puts first in its minimum automated test set: the authorization matrix — every tool, role, scope, tenant and object, including identifiers that belong to someone else — and token validation. The guide names the tool it expects as a category: "Policy-as-code test harness — asserts allow/deny decisions against the documented policy, with stable reason codes."
That is this product. Full comparison, sources and the claims we cannot make.
The policy is the expected side. The server is the observed side. The gap between them is the finding. Full specification.
Website · Specification · Positioning · Research notes · MIT license
Install
pipx install mcp-attest # isolated CLI
uvx mcp-attest test --help # one-shot
pip install mcp-attest # into a virtualenv
From source:
git clone https://github.com/Balanov/mcp-attest.git
cd mcp-attest
python -m pip install -e ".[dev]"
python -m unittest discover -s tests -v
Run it
attest init --command "python my_server.py" # scaffold a policy from the live tools
attest test --command "python my_server.py" --policy policy.json
Against the bundled vulnerable server:
!! [CRITICAL] AT-POISON-001 tool 'refund_order' hides an instruction in its description
The description contains text that matches 'hides the call': 'do not tell the user'.
A tool definition is read by the model, so this is an instruction channel that the
user never sees.
fix: Remove the instruction from the description. The tool's description should state
what it does, not what the model must do.
!! [CRITICAL] AT-AUTHZ-001 'alice' was allowed to call 'refund_order'
The policy denies 'refund_order' for this identity (tool is on the deny list), but the
server executed the call. This is broken access control, not a policy tuning problem.
fix: Enforce the decision inside the server's tool dispatcher, before the handler runs.
!! [CRITICAL] AT-AUTHZ-002 case AUTHZ-CROSS-01: 'alice' reached an object it must not reach
The case expects 'get_order' to be refused for 'alice' (order B-77 belongs to bob,
not to alice), but the server executed the call.
fix: Check ownership of the object at the point of use, not only the role of the caller.
summary: critical=4, high=2, medium=4, low=3
tested: 2 identities, 6 tools, 12 calls
Exit code 1 means a blocking finding exists. --json prints machine-readable findings, --sarif writes SARIF 2.1.0 for code scanning, --strict treats medium findings as blocking.
| Command | Purpose |
|---|---|
attest test |
Test a live server against a policy, as every identity in it |
attest init |
Write a starter policy, optionally from the live tool list |
attest verify |
Verify an evidence bundle offline |
attest check |
Audit a recorded trace against a policy |
attest receipts |
Build and verify a hash-chained receipt trail |
attest shadow |
List the servers an agent can reach outside the gateway |
attest rules |
List the enforced rules |
Check an export you already have
If your agent already emits OpenTelemetry spans, attest check reads the export directly. You do not need a second instrumentation path to prove what your agent was allowed to do:
attest check --policy policy.json --otlp otel-export.json
The reader takes a single OTLP JSON payload or newline-delimited payloads, and skips everything that is not an MCP span with a warning. It understands four span names:
| Span | Attributes | Meaning |
|---|---|---|
mcp.tool.call |
mcp.actor, mcp.tool.name, mcp.tool.arguments, mcp.policy.decision, mcp.policy.reason |
one tool invocation and the decision it received |
mcp.tools.list |
mcp.actor, mcp.tools |
the tool list an actor received |
mcp.client.servers |
mcp.actor, mcp.servers |
the servers a client is configured with |
mcp.gateway.route |
mcp.server, mcp.tools |
the tools a gateway routes for a server |
The rules
Live rules run against a server you point it at. Each maps to a test in the OWASP MCP Security Testing Guide.
| ID | Rule | OWASP | The failure it catches |
|---|---|---|---|
| AT-AUTHZ-001 | policy enforced per identity | MCPSTG-AUTHZ-01 | The server allows what your policy denies |
| AT-AUTHZ-002 | cross-user objects refused | MCPSTG-AUTHZ-03 | A valid identifier belonging to another principal |
| AT-TOKEN-001 | tokens validated | MCPSTG-AUTHN-04 | A malformed, tampered or expired token gets through |
| AT-POISON-001 | no hidden instructions | MCPSTG-TOOL-01 | Instructions smuggled into tool metadata |
| AT-DRIFT-001 | no manifest drift | MCPSTG-SUPP-01 | A tool added or changed after approval (rug pull) |
| AT-SECRET-001 | no secrets in output | MCPSTG-SECR-01 | A credential in the tool output |
| AT-FAILCLOSED-001 | fail closed on invalid input | MCPSTG-CONF-01 | A call executed with missing required arguments |
| AT-INJECT-001 | no injected instructions in output | MCPSTG-TOOL-01 | Instructions aimed at the model inside a tool result |
| AT-SINK-001 | guarded execution sinks | MCPSTG-TOOL-02 | Model input reaching a shell, a file or a URL |
| AT-SCHEMA-001 | strict tool schemas | MCPSTG-TOOL-04 | Untyped, unbounded or free-form arguments |
| AT-APPROVAL-001 | declared risk and approval | MCPSTG-AUTHZ-02 | A state-changing tool that declares no risk |
| AT-ERROR-001 | clean error messages | MCPSTG-CONF-03 | Stack traces and internal paths in errors |
Trace rules run against a recorded session and are unchanged since 0.1: C1 default-deny, C2 list/call parity, C3 narrowing only, C4 credential separation, C5 fail closed, C6 enforcement parity, C7 shadow MCP, C8 receipt chain, C9 description integrity.
The policy file
One file holds both sides of the comparison: what each identity may call, and how that identity presents itself.
{
"version": 1,
"default_decision": "deny",
"clients": {
"alice": {
"tools": {
"allow": ["list_orders", "get_order"],
"deny": ["refund_order"]
},
"identity": {
"headers": { "Authorization": "Bearer ${ALICE_TOKEN}" }
}
}
},
"cases": [
{
"id": "AUTHZ-CROSS-01",
"identity": "alice",
"tool": "get_order",
"args": { "order_id": "B-77" },
"expect": "deny",
"reason": "order B-77 belongs to bob, not to alice"
}
],
"probes": {
"get_order": { "order_id": "A-1" }
}
}
- identity — how to present yourself.
headersfor HTTP servers,envfor stdio servers.${VAR}is expanded from the environment, so credentials never live in the file. - cases — explicit expectations that a generated probe cannot express: an identifier owned by someone else, an amount above a threshold, a tool that must be refused.
- probes — the arguments to send for a tool. Pinning arguments is also how you opt in to calling a tool that changes state.
Evidence
attest test --url https://mcp.example.com/mcp --policy policy.json --evidence bundle.json
attest verify --bundle bundle.json
# bundle: ok (14 records, key=yes)
Each record carries the request, the response, the identity, the timestamp, the expected decision and the observed decision, with anything that looks like a credential redacted. The records are hash-chained and, with MCP_ATTEST_KEY set, signed with HMAC-SHA256.
That is what turns "our agent policy is enforced" from a sentence in a security questionnaire into an artifact a third party can check.
Use it in CI
- uses: Balanov/mcp-attest@v0
with:
policy: .attest/policy.json
command: "python my_server.py"
sarif: mcp-attest.sarif
The action fails the job on any critical or high finding, and the SARIF output lands in code scanning next to the rest of the repository's security results.
What mcp-attest is not
- Not a gateway, proxy or enforcement point. It cannot block a call.
- Not a scanner. It does not judge a schema or a description; it exercises a server.
- Not a model-based red team. Nothing here calls an LLM, so nothing here is non-deterministic or billable.
- Not a PKI. A hash chain and an HMAC prove order and integrity, not identity.
- Not a hosted service. No account, no telemetry, no network calls beyond the server under test.
Development
python -m pip install -e ".[dev]"
python -m unittest discover -s tests -v
The suite starts real MCP servers — one over stdio, one over HTTP, one with broken token checks — and asserts on what the harness finds. CI runs it on Python 3.10–3.13 and publishes to PyPI from a tagged release through trusted publishing, with no API token in the repository.
License
MIT. Fork it, modify it, embed it, ship it.
Metadata
Release files for mcp-attest 1.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| mcp_attest-1.1.1.tar.gz | 74.8 kB | With provenance | Details for mcp_attest-1.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| mcp_attest-1.1.1-py3-none-any.whl | Python 3 | none | any | Details for mcp_attest-1.1.1-py3-none-any.whl |
Total release size: 128.5 kB
Release files / mcp_attest-1.1.1.tar.gz
| Download URL | mcp_attest-1.1.1.tar.gz |
|---|---|
| Size | 74.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0a5ac79f0baf2d6b7cb96cd3a409ebb904eaa73e82045e41a12ad08880192dbc
|
|
BLAKE2b-256 checksum How to use checksums |
d28304b9d9065e553e586262a49c97077b6a2d4cd6c9801485989d4c78d1f611
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 13, 2026.
Transparency logRelease files / mcp_attest-1.1.1-py3-none-any.whl
| Download URL | mcp_attest-1.1.1-py3-none-any.whl |
|---|---|
| Size | 53.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
45cbcf745c7966cb4b0d3d64b3ac91c752378470d1e4790c8592ffa2a0a8e236
|
|
BLAKE2b-256 checksum How to use checksums |
39831289b2ad4f4449c3adcb1430727888a0a70a1123834e417ee758e37b29b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 13, 2026.
Transparency logSecurity for mcp-attest 1.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for mcp_attest-1.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 13, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.